From 4a3fcc2d05e57c3771ffb8e8862d9a3691c94cf2 Mon Sep 17 00:00:00 2001 From: Thomas Humer Date: Sat, 31 Jan 2026 12:22:35 +0100 Subject: [PATCH 1/7] feat: Add asset lot tracking functionality - Introduced AssetLots and LotMovements tables to manage asset lot tracking. - Enhanced CryptoTransactions and CryptoTrades with LotAssignmentConfirmed and ResultingLotId fields. - Implemented LotService for managing lot creation, transfer, and sales, including FIFO allocation suggestions. - Added methods for retrieving available lots and generating lots from existing trades. - Included logging for lot operations and error handling for trade and lot assignments. --- plans/krypto-steuer-austria-asset-tracking.md | 780 ++++++++++++ src/CryptoTracker.Client/Pages/Lots.razor | 213 ++++ src/CryptoTracker.Client/Pages/Lots.razor.cs | 161 +++ .../Pages/Transaktionen.razor | 212 +++- .../Pages/Transaktionen.razor.cs | 281 +++++ .../Shared/Api/ILotsApi.cs | 27 + src/CryptoTracker.Client/Shared/LotDTOs.cs | 120 ++ .../Shared/LotSelector.razor | 314 +++++ .../Components/Layout/NavMenu.razor | 1 + .../Controllers/LotsController.cs | 306 +++++ .../DbContext/CryptoTrackerDbContext.cs | 91 +- src/CryptoTracker/Entities/AssetLot.cs | 155 +++ src/CryptoTracker/Entities/CryptoTrade.cs | 22 +- .../Entities/CryptoTransaction.cs | 30 +- src/CryptoTracker/Entities/LotMovement.cs | 124 ++ ...0130220034_AddAssetLotTracking.Designer.cs | 1107 +++++++++++++++++ .../20260130220034_AddAssetLotTracking.cs | 257 ++++ .../CryptoTrackerDbContextModelSnapshot.cs | 236 ++++ src/CryptoTracker/Services/LotService.cs | 602 +++++++++ src/CryptoTracker/Startup.cs | 2 + src/CryptoTracker/wwwroot/app.css | 412 ++++++ 21 files changed, 5391 insertions(+), 62 deletions(-) create mode 100644 plans/krypto-steuer-austria-asset-tracking.md create mode 100644 src/CryptoTracker.Client/Pages/Lots.razor create mode 100644 src/CryptoTracker.Client/Pages/Lots.razor.cs create mode 100644 src/CryptoTracker.Client/Shared/Api/ILotsApi.cs create mode 100644 src/CryptoTracker.Client/Shared/LotDTOs.cs create mode 100644 src/CryptoTracker.Client/Shared/LotSelector.razor create mode 100644 src/CryptoTracker/Controllers/LotsController.cs create mode 100644 src/CryptoTracker/Entities/AssetLot.cs create mode 100644 src/CryptoTracker/Entities/LotMovement.cs create mode 100644 src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.Designer.cs create mode 100644 src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.cs create mode 100644 src/CryptoTracker/Services/LotService.cs diff --git a/plans/krypto-steuer-austria-asset-tracking.md b/plans/krypto-steuer-austria-asset-tracking.md new file mode 100644 index 0000000..5e1d6ca --- /dev/null +++ b/plans/krypto-steuer-austria-asset-tracking.md @@ -0,0 +1,780 @@ +# Plan: Krypto-Steuer Österreich – Feingranulares Asset-Tracking & Finanzamt-Reporting + +## Zusammenfassung + +Dieses Dokument beschreibt die Erweiterung des CryptoTracker-Systems für die österreichische Krypto-Steuer. +Kern ist ein **feingranulares Asset-Tracking-System**, das: +1. Jeden Krypto-Betrag mit seiner Herkunft (Kaufdatum, Quelle, Preis) verknüpft +2. Bei Transfers **keine automatische Zuordnung (FIFO)** vornimmt – der Benutzer entscheidet explizit +3. **Altbestand** (vor 28.02.2021) und **Neubestand** getrennt verwaltet +4. Finanzamt-konforme Reports generiert + +--- + +## 1. Rechtliche Grundlagen Österreich + +### 1.1 Steuerpflicht nach Ökosozialer Steuerreform (seit 01.03.2022) + +| Vorgang | Steuerpflicht | Steuersatz | +|---------|---------------|------------| +| Kauf Fiat → Krypto | ❌ Steuerfrei | - | +| Tausch Krypto → Krypto | ❌ Steuerfrei | - (Anschaffungskosten werden weitergegeben) | +| Transfer zwischen eigenen Wallets | ❌ Steuerfrei | - | +| Verkauf Krypto → Fiat (Neubestand) | ✅ Steuerpflichtig | 27,5% KESt | +| Verkauf Altbestand (vor 28.02.2021) | ❌ Steuerfrei | - (Haltefrist > 1 Jahr erfüllt) | +| Mining, Lending | ✅ Bei Zufluss + Verkauf | 27,5% | +| Staking, Airdrops, Hardforks | ❌ Bei Zufluss steuerfrei | 27,5% bei Verkauf | + +### 1.2 Altbestand vs. Neubestand + +- **Altbestand**: Erworben **bis einschließlich 28. Februar 2021** + - Nach einem Jahr Haltefrist (also ab 01.03.2022) komplett steuerfrei + - Kann frei verkauft werden ohne KESt + +- **Neubestand**: Erworben **ab 1. März 2021** + - Kein Haltefrist-Vorteil mehr + - Verkauf gegen Fiat: immer 27,5% KESt auf Gewinn + +### 1.3 Einkünfteermittlung: Gleitender Durchschnittspreis (ACB) + +Ab 01.01.2023 gilt gemäß BMF-Kryptowährungsverordnung: +- Assets auf **derselben Kryptowährungsadresse** werden mit dem **gleitenden Durchschnittspreis (ACB = Average Cost Basis)** bewertet +- **NICHT in den ACB eingehen**: + - Altbestand (separate Tranchenverwaltung) + - Pauschal angesetzte Anschaffungskosten beim KESt-Abzug +- Für Veräußerungen **vor 31.12.2022** galt FIFO oder freie Zuordnung + +### 1.4 Was das Finanzamt braucht + +1. **Gewinn/Verlust-Aufstellung** pro Steuerjahr +2. **Nachweis Altbestand**: Kaufdatum, Kaufpreis, Wallet-Adressen +3. **Einzeltransaktionsaufstellung** bei Prüfung +4. **Mittelherkunftsnachweis** bei Auszahlungen auf Bankkonten + +--- + +## 2. Dein Anforderungsprofil + +### 2.1 Feingranulare Kontrolle statt FIFO + +> "Ich möchte nie eine Default-Annahme, wenn es eine Alt- oder Neubestand-Verschiebung gibt" + +**Lösung**: Jede Coin-Menge wird als **"Lot"** (Tranche) mit Herkunft gespeichert. Bei Transfers/Verkäufen wählt der User explizit aus, welche Lots verwendet werden. + +### 2.2 Verbindung von Transaktionen + +> "Obwohl die meisten Transaktionen (in and out) miteinander verbunden sind, gibt es welche die nicht verbunden sind" + +**Lösung**: Unverknüpfte Transaktionen werden markiert und erfordern manuelle Zuordnung der Lot-Herkunft. + +### 2.3 Geldfluss-Visualisierung + +> "Wie visualisiere ich den Geldfluss – primär fürs Finanzamt, aber auch für mich" + +**Lösung**: Sankey-Diagramme und Fluss-Tabellen, die zeigen: +- Woher kam das Asset (Kauf, Staking, Transfer von Wallet X) +- Wohin ging es (Transfer, Verkauf) +- Steuerliche Klassifikation (Altbestand/Neubestand) + +--- + +## 3. Datenmodell-Erweiterungen + +### 3.1 Neue Entity: `AssetLot` (Tranche) + +Jedes "Lot" repräsentiert eine spezifische Menge eines Assets mit eindeutiger Herkunft. + +```csharp +public class AssetLot +{ + public int Id { get; set; } + + // Welches Asset (z.B. BTC, ETH) + public string Symbol { get; set; } = string.Empty; + + // Wo liegt das Lot aktuell? + public int CurrentWalletId { get; set; } + public Wallet CurrentWallet { get; set; } = null!; + + // Aktuelle Menge (kann durch Teil-Verkäufe abnehmen) + public decimal Quantity { get; set; } + + // Ursprüngliche Menge bei Erstellung des Lots + public decimal OriginalQuantity { get; set; } + + // === Herkunftsinformationen === + + /// + /// Wann wurde dieses Lot ursprünglich erworben? + /// Entscheidend für Altbestand/Neubestand-Klassifizierung + /// + public DateTimeOffset AcquisitionDate { get; set; } + + /// + /// Anschaffungskosten pro Einheit in EUR + /// + public decimal AcquisitionPriceEur { get; set; } + + /// + /// Gesamte Anschaffungskosten (inkl. Gebühren) + /// + public decimal TotalAcquisitionCostEur { get; set; } + + /// + /// Wie wurde das Lot erworben? + /// + public LotAcquisitionType AcquisitionType { get; set; } + + /// + /// Referenz zur ursprünglichen Transaktion/Trade + /// + public int? SourceTradeId { get; set; } + public CryptoTrade? SourceTrade { get; set; } + + public int? SourceTransactionId { get; set; } + public CryptoTransaction? SourceTransaction { get; set; } + + /// + /// Bei Krypto-zu-Krypto-Tausch: Lot des eingetauschten Assets + /// Ermöglicht Rückverfolgung der Anschaffungskosten + /// + public int? ParentLotId { get; set; } + public AssetLot? ParentLot { get; set; } + + /// + /// Ist Altbestand (vor 28.02.2021)? + /// + public bool IsAltbestand => AcquisitionDate <= new DateTimeOffset(2021, 2, 28, 23, 59, 59, TimeSpan.Zero); + + /// + /// Benutzernotiz zur Herkunft + /// + public string? Note { get; set; } + + /// + /// Ist dieses Lot vollständig aufgebraucht? + /// + public bool IsFullyConsumed => Quantity <= 0; +} + +public enum LotAcquisitionType +{ + /// Kauf mit Fiat (EUR, USD etc.) + FiatPurchase, + + /// Erhalt durch Krypto-zu-Krypto-Tausch + CryptoSwap, + + /// Transfer von anderer Börse/Wallet (Herkunft bekannt) + InternalTransfer, + + /// Transfer von externer Quelle (Herkunft muss dokumentiert werden) + ExternalTransfer, + + /// Mining-Rewards + Mining, + + /// Staking-Rewards + Staking, + + /// Lending-Zinsen + Lending, + + /// Airdrop + Airdrop, + + /// Hardfork + Hardfork, + + /// Schenkung erhalten + Gift, + + /// Manueller Eintrag (z.B. für Altbestand-Import) + Manual +} +``` + +### 3.2 Neue Entity: `LotMovement` (Lot-Bewegung) + +Dokumentiert jede Verwendung eines Lots. + +```csharp +public class LotMovement +{ + public int Id { get; set; } + + /// + /// Welches Lot wurde verwendet? + /// + public int LotId { get; set; } + public AssetLot Lot { get; set; } = null!; + + /// + /// Wieviel wurde von diesem Lot verwendet? + /// + public decimal Quantity { get; set; } + + /// + /// Zeitpunkt der Bewegung + /// + public DateTimeOffset DateTime { get; set; } + + /// + /// Art der Bewegung + /// + public LotMovementType MovementType { get; set; } + + /// + /// Bei Verkauf: Erlös pro Einheit in EUR + /// + public decimal? SalePriceEur { get; set; } + + /// + /// Bei Verkauf: Realisierter Gewinn/Verlust + /// + public decimal? RealizedGainEur { get; set; } + + /// + /// War diese Bewegung steuerfrei (Altbestand)? + /// + public bool IsTaxFree { get; set; } + + /// + /// Referenz zur auslösenden Transaktion/Trade + /// + public int? TradeId { get; set; } + public CryptoTrade? Trade { get; set; } + + public int? TransactionId { get; set; } + public CryptoTransaction? Transaction { get; set; } + + /// + /// Bei Transfer: Neues Lot auf Ziel-Wallet + /// + public int? ResultingLotId { get; set; } + public AssetLot? ResultingLot { get; set; } +} + +public enum LotMovementType +{ + /// Transfer zu anderem Wallet (gleiches Asset) + Transfer, + + /// Verkauf gegen Fiat + FiatSale, + + /// Verwendung in Krypto-zu-Krypto-Tausch + CryptoSwap, + + /// Gebühr bezahlt + Fee, + + /// Schenkung gegeben + GiftOut +} +``` + +### 3.3 Erweiterung bestehender Entities + +#### CryptoTransaction + +```csharp +public class CryptoTransaction : IFlow +{ + // ... bestehende Properties ... + + /// + /// Bei Send: Welche Lots wurden für diese Transaktion verwendet? + /// Bei Receive ohne verknüpfte Gegentransaktion: Manuelle Lot-Zuweisung erforderlich + /// + public ICollection LotMovements { get; set; } = new List(); + + /// + /// Wurde die Lot-Zuordnung für diese Transaktion bestätigt? + /// + public bool LotAssignmentConfirmed { get; set; } + + /// + /// Benötigt manuelle Lot-Zuordnung? + /// + public bool RequiresLotAssignment => + TransactionType == TransactionType.Receive && + OppositeTransactionId == null && + !LotAssignmentConfirmed; +} +``` + +#### CryptoTrade + +```csharp +public class CryptoTrade : IFlow +{ + // ... bestehende Properties ... + + /// + /// Bei Sell: Welche Lots wurden verkauft? + /// Bei Buy mit Krypto-Zahlung: Welche Lots wurden verwendet? + /// + public ICollection LotMovements { get; set; } = new List(); + + /// + /// Wurde die Lot-Zuordnung für diesen Trade bestätigt? + /// + public bool LotAssignmentConfirmed { get; set; } + + /// + /// Bei Buy: Erstelltes Lot + /// + public int? ResultingLotId { get; set; } + public AssetLot? ResultingLot { get; set; } +} +``` + +--- + +## 4. Workflow: Feingranulare Lot-Zuordnung + +### 4.1 Szenario: Kauf mit Fiat + +``` +User kauft 1 BTC für 50.000€ auf Bitpanda am 15.03.2024 +``` + +**Automatische Aktion:** +1. Trade wird erfasst (TradeType: Buy) +2. Neues `AssetLot` wird erstellt: + - Symbol: BTC + - CurrentWallet: Bitpanda + - Quantity: 1.0 + - AcquisitionDate: 15.03.2024 + - AcquisitionPriceEur: 50.000€ + - AcquisitionType: FiatPurchase + - IsAltbestand: false (Neubestand) + +### 4.2 Szenario: Transfer zwischen Börsen + +``` +User transferiert 0.5 BTC von Bitpanda zu Binance +``` + +**Workflow mit manueller Auswahl:** + +1. **Send-Transaktion** wird auf Bitpanda erfasst +2. **Receive-Transaktion** wird auf Binance erfasst +3. Transaktionen werden verknüpft (OppositeTransactionId) + +4. **UI zeigt Dialog zur Lot-Auswahl:** + ``` + ┌────────────────────────────────────────────────────────┐ + │ Transfer 0.5 BTC: Bitpanda → Binance │ + │ │ + │ Welche Lots sollen verwendet werden? │ + │ │ + │ ┌─────────────────────────────────────────────────────┐│ + │ │ ☑ Lot #1: 0.3 BTC ││ + │ │ Erworben: 15.01.2020 (ALTBESTAND ✓) ││ + │ │ Kaufpreis: 8.500€ ││ + │ │ Verwenden: [0.3 ] BTC ││ + │ │─────────────────────────────────────────────────────││ + │ │ ☑ Lot #2: 1.0 BTC ││ + │ │ Erworben: 15.03.2024 (Neubestand) ││ + │ │ Kaufpreis: 50.000€ ││ + │ │ Verwenden: [0.2 ] BTC ││ + │ │─────────────────────────────────────────────────────││ + │ │ ☐ Lot #3: 0.5 BTC ││ + │ │ Erworben: 01.06.2024 (Neubestand) ││ + │ │ Kaufpreis: 65.000€ ││ + │ └─────────────────────────────────────────────────────┘│ + │ │ + │ Ausgewählt: 0.5 BTC (0.3 Altbestand + 0.2 Neubestand) │ + │ │ + │ [Abbrechen] [Zuordnung speichern]│ + └────────────────────────────────────────────────────────┘ + ``` + +5. **Nach Bestätigung:** + - Lot #1: Quantity = 0.3 - 0.3 = 0 (vollständig verbraucht) + - Lot #2: Quantity = 1.0 - 0.2 = 0.8 (teilweise verbraucht) + - Zwei neue Lots auf Binance: + - Lot #4: 0.3 BTC, Altbestand, ParentLot = #1 + - Lot #5: 0.2 BTC, Neubestand, ParentLot = #2 + +### 4.3 Szenario: Verkauf gegen Fiat + +``` +User verkauft 0.5 BTC für 35.000€ +``` + +**Workflow:** + +1. **UI zeigt verfügbare Lots:** + ``` + ┌────────────────────────────────────────────────────────┐ + │ Verkauf 0.5 BTC für 35.000€ │ + │ │ + │ Welche Lots sollen verkauft werden? │ + │ │ + │ ┌─────────────────────────────────────────────────────┐│ + │ │ ☑ Lot #4: 0.3 BTC (ALTBESTAND) ││ + │ │ Kaufpreis: 8.500€ → Verkauf: 21.000€ ││ + │ │ Gewinn: 12.500€ → STEUERFREI ✓ ││ + │ │─────────────────────────────────────────────────────││ + │ │ ☑ Lot #5: 0.2 BTC (Neubestand) ││ + │ │ Kaufpreis: 10.000€ → Verkauf: 14.000€ ││ + │ │ Gewinn: 4.000€ → KESt: 1.100€ ││ + │ └─────────────────────────────────────────────────────┘│ + │ │ + │ Zusammenfassung: │ + │ • Steuerfreier Gewinn (Altbestand): 12.500€ │ + │ • Steuerpflichtiger Gewinn: 4.000€ │ + │ • Zu zahlende KESt (27,5%): 1.100€ │ + │ │ + │ [Abbrechen] [Verkauf bestätigen]│ + └────────────────────────────────────────────────────────┘ + ``` + +### 4.4 Szenario: Unverknüpfte Receive-Transaktion + +``` +User erhält 0.5 BTC auf Binance, aber keine zugehörige Send-Transaktion ist erfasst +``` + +**Workflow:** + +1. System erkennt: Receive ohne OppositeTransaction +2. **Transaktion wird als "Lot-Zuordnung erforderlich" markiert** +3. UI zeigt Warnung in Transaktionsliste + +4. **User muss Herkunft dokumentieren:** + ``` + ┌────────────────────────────────────────────────────────┐ + │ ⚠ Unverknüpfte Einzahlung: 0.5 BTC │ + │ │ + │ Bitte dokumentieren Sie die Herkunft: │ + │ │ + │ Herkunftstyp: │ + │ ○ Transfer von eigener Wallet (nicht in System) │ + │ ○ Kauf auf anderer Börse (mit Fiat) │ + │ ○ Mining-Reward │ + │ ○ Staking-Reward │ + │ ○ Airdrop │ + │ ○ Schenkung │ + │ ● Altbestand-Import │ + │ │ + │ Ursprüngliches Kaufdatum: [15.01.2020 ] │ + │ Ursprünglicher Kaufpreis: [8.500 ] EUR │ + │ Notiz: [Kauf auf Kraken, Wallet geschlossen____] │ + │ │ + │ [Abbrechen] [Lot erstellen] │ + └────────────────────────────────────────────────────────┘ + ``` + +--- + +## 5. Finanzamt-Reporting + +### 5.1 Steuerbericht-Struktur + +``` +┌────────────────────────────────────────────────────────────┐ +│ KRYPTO-STEUERBERICHT 2025 │ +│ Max Mustermann │ +│ Erstellt: 15.04.2026 │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ 1. ZUSAMMENFASSUNG │ +│ ───────────────────────────────────────────────────────── │ +│ │ +│ Einkünfte aus Kapitalvermögen (§ 27b EStG): │ +│ │ +│ Realisierte Gewinne (Neubestand): 15.420,00 € │ +│ Realisierte Verluste (Neubestand): -2.340,00 € │ +│ ────────────────────────────────────────────────────── │ +│ Netto Einkünfte: 13.080,00 € │ +│ KESt (27,5%): 3.597,00 € │ +│ │ +│ Steuerfreie Gewinne (Altbestand): 42.500,00 € │ +│ │ +│ Laufende Einkünfte (Mining, Lending): │ +│ Mining: 1.200,00 € │ +│ Lending: 450,00 € │ +│ ────────────────────────────────────────────────────── │ +│ Summe (27,5% bei Zufluss): 1.650,00 € │ +│ Bereits versteuert bei Zufluss: 453,75 € │ +│ │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ 2. ALTBESTAND-NACHWEIS │ +│ ───────────────────────────────────────────────────────── │ +│ │ +│ Die folgenden Assets wurden vor dem 28.02.2021 erworben │ +│ und sind daher steuerfrei: │ +│ │ +│ ┌──────────┬──────────┬────────────┬─────────────────────┐│ +│ │ Asset │ Menge │ Kaufdatum │ Kaufpreis (€) ││ +│ ├──────────┼──────────┼────────────┼─────────────────────┤│ +│ │ BTC │ 2.5 │ 15.01.2020 │ 21.250,00 ││ +│ │ ETH │ 10.0 │ 22.06.2020 │ 2.150,00 ││ +│ │ LTC │ 50.0 │ 03.12.2019 │ 2.300,00 ││ +│ └──────────┴──────────┴────────────┴─────────────────────┘│ +│ │ +│ Gesamt Altbestand Anschaffungskosten: 25.700,00 € │ +│ Verkauft in 2025: │ +│ BTC 1.0 für 68.200€ → Gewinn 42.500€ (steuerfrei) │ +│ │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ 3. EINZELTRANSAKTIONEN (Verkäufe) │ +│ ───────────────────────────────────────────────────────── │ +│ │ +│ ┌────────────┬───────┬────────┬──────────┬────────┬──────┐│ +│ │ Datum │ Asset │ Menge │ Erlös(€) │ AK(€) │ G/V ││ +│ ├────────────┼───────┼────────┼──────────┼────────┼──────┤│ +│ │ 15.03.2025 │ BTC │ 0.5 │ 34.100 │ 12.500 │21.600││ +│ │ 22.05.2025 │ ETH │ 5.0 │ 17.500 │ 11.250 │ 6.250││ +│ │ ... │ ... │ ... │ ... │ ... │ ... ││ +│ └────────────┴───────┴────────┴──────────┴────────┴──────┘│ +│ │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ 4. ASSET-FLUSS-DOKUMENTATION │ +│ ───────────────────────────────────────────────────────── │ +│ │ +│ BTC-Fluss 2025: │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Bitpanda │───▶│ Ledger │───▶│ Verkauf │ │ +│ │ Kauf 1.0 │ │ Transfer │ │ 0.5 │ │ +│ │ 50.000€ │ │ │ │ 34.100€ │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ▲ │ +│ │ Herkunft: Neubestand (15.03.2024) │ +│ │ Anschaffungskosten: 25.000€ (für 0.5 BTC) │ +│ │ Gewinn: 9.100€ → KESt: 2.502,50€ │ +│ │ +└────────────────────────────────────────────────────────────┘ +``` + +### 5.2 Für FinanzOnline relevante Kennzahlen + +| Kennzahl | Beschreibung | Formular-Feld | +|----------|--------------|---------------| +| KZ 981 | Kapitalerträge aus Kryptowährungen | E1 Anlage KAP | +| KZ 982 | Verluste aus Kryptowährungen | E1 Anlage KAP | +| KZ 994 | Bereits abgeführte KESt | E1 Anlage KAP | + +--- + +## 6. Geldfluss-Visualisierung + +### 6.1 Sankey-Diagramm + +Für die Visualisierung des Asset-Flusses empfehle ich ein Sankey-Diagramm: + +``` + ┌──────────────────┐ + Käufe (Fiat) │ │ Verkäufe (Fiat) + ─────────────────▶ BTC Gesamtpool ▶───────────────────── + 50.000€ │ │ 68.200€ + │ 2.5 BTC │ + Transfers In │ │ Transfers Out + ─────────────────▶ ▶───────────────────── + (von Kraken) │ │ (zu Ledger) + └──────────────────┘ + │ + ▼ + Altbestand: 1.5 BTC + Neubestand: 1.0 BTC +``` + +### 6.2 Timeline-Ansicht + +``` +2020 ──●────────────────────────────────────────────────────▶ + │ + └─ 15.01: Kauf 2.5 BTC @ 8.500€ (Altbestand) + +2021 ──────────●───────────────────────────────────────────▶ + │ + └─ 28.02: STICHTAG ALTBESTAND + +2024 ──────────────────●───────────────────────────────────▶ + │ + └─ 15.03: Kauf 1.0 BTC @ 50.000€ (Neubestand) + +2025 ──────────────────────────●───────●───────────────────▶ + │ │ + │ └─ 22.05: Verkauf 0.5 BTC (Neubestand) + │ Gewinn: 9.100€ → KESt: 2.502,50€ + │ + └─ 15.03: Verkauf 1.0 BTC (Altbestand) + Gewinn: 42.500€ → STEUERFREI +``` + +--- + +## 7. Implementierungs-Arbeitspakete + +### AP 1: Datenmodell & Migration + +**Aufwand:** ~8h + +1. Neue Entities erstellen: + - `AssetLot` + - `LotMovement` +2. Bestehende Entities erweitern: + - `CryptoTransaction` + - `CryptoTrade` +3. EF Core Migration erstellen +4. Indizes für Performance + +### AP 2: Lot-Service + +**Aufwand:** ~12h + +```csharp +public interface ILotService +{ + // Lot-Management + Task CreateLotFromPurchase(CryptoTrade trade); + Task CreateLotFromTransfer(CryptoTransaction transaction, AssetLot parentLot, decimal quantity); + Task CreateManualLot(ManualLotRequest request); + + // Lot-Abfragen + Task> GetAvailableLots(int walletId, string symbol); + Task> GetAltbestandLots(int walletId, string symbol); + Task> GetNeubestandLots(int walletId, string symbol); + + // Lot-Verwendung + Task UseLotForSale(int lotId, decimal quantity, decimal salePriceEur); + Task> TransferLots(int transactionId, IList allocations); + + // Steuerberechnung + Task CalculateTaxForSale(IList allocations, decimal totalSalePriceEur); +} +``` + +### AP 3: Unverknüpfte Transaktionen erkennen + +**Aufwand:** ~4h + +```csharp +public interface ITransactionLinkingService +{ + Task> GetUnlinkedTransactions(); + Task> GetTransactionsRequiringLotAssignment(); + Task LinkTransactions(int sendId, int receiveId); +} +``` + +### AP 4: UI – Lot-Zuordnungs-Dialog + +**Aufwand:** ~16h + +1. Wiederverwendbare Komponente `LotSelector.razor` +2. Integration in: + - Transfer-Workflow + - Verkauf-Workflow + - Unverknüpfte Transaktionen + +### AP 5: UI – Unverknüpfte Transaktionen-Dashboard + +**Aufwand:** ~8h + +1. Neue Seite `/unverknuepft` +2. Liste aller problematischen Transaktionen +3. Wizard zur Behebung + +### AP 6: UI – Geldfluss-Visualisierung + +**Aufwand:** ~12h + +1. Sankey-Diagramm-Komponente (z.B. mit Blazor + Chart.js oder D3.js) +2. Timeline-Ansicht +3. Export als PDF/PNG für Finanzamt + +### AP 7: Steuerbericht-Generator + +**Aufwand:** ~16h + +```csharp +public interface ITaxReportService +{ + Task GenerateAnnualReport(int year); + Task ExportToPdf(TaxReport report); + Task ExportToCsv(TaxReport report); + Task GetFinanzOnlineData(int year); +} +``` + +### AP 8: Altbestand-Import + +**Aufwand:** ~8h + +1. CSV-Import für historische Daten +2. Manuelle Erfassung von Altbestand-Lots +3. Validierung der Daten + +### AP 9: Tests + +**Aufwand:** ~12h + +1. Unit Tests für Lot-Service +2. Unit Tests für Steuerberechnung +3. Integration Tests für Workflows + +--- + +## 8. Prioritäten + +### Phase 1: Fundament (MVP) +1. **AP 1**: Datenmodell +2. **AP 2**: Lot-Service (Basis) +3. **AP 4**: Lot-Zuordnungs-Dialog + +### Phase 2: Compliance +4. **AP 3**: Unverknüpfte Transaktionen +5. **AP 5**: Dashboard für Probleme +6. **AP 7**: Steuerbericht-Generator +7. **AP 8**: Altbestand-Import + +### Phase 3: Visualisierung +8. **AP 6**: Geldfluss-Visualisierung +9. **AP 9**: Tests + +--- + +## 9. Offene Fragen + +1. **Gleitender Durchschnitt vs. Einzelzuordnung**: + - Das BMF schreibt ACB für Assets auf derselben Adresse vor + - Wie soll das System damit umgehen, wenn User explizite Lots wählen will? + - Vorschlag: Beide Modi anbieten (ACB-konform vs. feingranular für eigene Dokumentation) + +2. **Krypto-zu-Krypto-Tausch**: + - Obwohl steuerfrei, müssen Anschaffungskosten weitergegeben werden + - Wie tief soll die Tranchenverfolgung gehen? + +3. **DeFi-Transaktionen**: + - Staking, Liquidity Providing etc. erzeugen komplexe Lot-Strukturen + - Welche DeFi-Aktivitäten sollen unterstützt werden? + +4. **Automatischer KESt-Abzug**: + - Bitpanda etc. führen seit 2024 KESt automatisch ab + - Wie soll das System bereits abgeführte KESt tracken? + +--- + +## 10. Nächste Schritte + +1. ✅ Plan erstellen und reviewen lassen +2. ⬜ Datenmodell finalisieren (Feedback einarbeiten) +3. ⬜ Migration erstellen und testen +4. ⬜ Lot-Service implementieren +5. ⬜ UI für Lot-Zuordnung bauen +6. ⬜ Steuerbericht-Generator entwickeln + +--- + +*Erstellt: 30.01.2026* +*Version: 1.0* diff --git a/src/CryptoTracker.Client/Pages/Lots.razor b/src/CryptoTracker.Client/Pages/Lots.razor new file mode 100644 index 0000000..2879a24 --- /dev/null +++ b/src/CryptoTracker.Client/Pages/Lots.razor @@ -0,0 +1,213 @@ +@page "/lots" +@using CryptoTracker.Client.Common +@using CryptoTracker.Client.Shared +@using CryptoTracker.Shared +@inject ILotsApi LotsApi +@inject IWalletApi WalletApi + +Lot-Verwaltung + + + + + +@if (WalletNavItems.Count > 0) +{ + +} + +@if (!IsLoading && !string.IsNullOrEmpty(SelectedWalletName)) +{ +
+

Lot-Übersicht für @SelectedWalletName

+ + @if (LotSummaries.Count == 0) + { +
+ Keine Lots für dieses Wallet vorhanden. +

+ +
+ } + else + { +
+ @foreach (var (symbol, summary) in LotSummaries.OrderBy(kvp => kvp.Key)) + { +
+
+ + @summary.LotCount Lots +
+
+
+ + @FormatHelper.FormatAmount(summary.TotalQuantity) +
+
+ + @FormatHelper.FormatAmount(summary.AltbestandQuantity) +
+
+ + @FormatHelper.FormatAmount(summary.NeubestandQuantity) +
+
+ + @FormatHelper.FormatEuro(summary.AverageAcquisitionPrice) +
+
+
+ } +
+ } +
+ + @if (!string.IsNullOrEmpty(SelectedSymbol)) + { +
+
+

@SelectedSymbol Lots

+ +
+ + @if (SymbolLots.Count == 0) + { +
Keine Lots für @SelectedSymbol
+ } + else + { + + + + + + + + + + + + + + + + + + + + } +
+ } + + @if (PendingAssignments.Count > 0) + { +
+

Ausstehende Lot-Zuordnungen

+

Diese Transaktionen/Trades benötigen eine Lot-Zuordnung.

+ + + + + + + + + + + + + + + + + + + + +
+ } +} + +@if (ErrorMessage != null) +{ + +} + +@* Generate Dialog *@ +@if (IsGenerateDialogOpen) +{ + +} + +@* Success Message *@ +@if (SuccessMessage != null) +{ +
+ @SuccessMessage +
+} diff --git a/src/CryptoTracker.Client/Pages/Lots.razor.cs b/src/CryptoTracker.Client/Pages/Lots.razor.cs new file mode 100644 index 0000000..c72e44c --- /dev/null +++ b/src/CryptoTracker.Client/Pages/Lots.razor.cs @@ -0,0 +1,161 @@ +using CryptoTracker.Client.Shared; +using CryptoTracker.Shared; +using Microsoft.AspNetCore.Components; + +namespace CryptoTracker.Client.Pages; + +public partial class Lots +{ + private bool IsLoading { get; set; } = true; + private string? ErrorMessage { get; set; } + private string? SuccessMessage { get; set; } + + private IList Wallets { get; set; } = new List(); + private List WalletNavItems { get; set; } = new(); + private string? SelectedWalletName { get; set; } + + private IDictionary LotSummaries { get; set; } = new Dictionary(); + private string? SelectedSymbol { get; set; } + private IList SymbolLots { get; set; } = new List(); + private IList PendingAssignments { get; set; } = new List(); + + // Generate Dialog + private bool IsGenerateDialogOpen { get; set; } + private bool IsGenerating { get; set; } + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + } + + private async Task LoadDataAsync() + { + IsLoading = true; + ErrorMessage = null; + + try + { + Wallets = await WalletApi.GetWalletsAsync(); + WalletNavItems = Wallets + .Select(w => new TopNavItem(w.Name, w.Name)) + .ToList(); + + if (Wallets.Count > 0 && string.IsNullOrEmpty(SelectedWalletName)) + { + SelectedWalletName = Wallets[0].Name; + } + + await LoadWalletDataAsync(); + await LoadPendingAssignmentsAsync(); + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Laden: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + private async Task LoadWalletDataAsync() + { + if (string.IsNullOrEmpty(SelectedWalletName)) return; + + try + { + LotSummaries = await LotsApi.GetLotSummaryByWalletAsync(SelectedWalletName); + SelectedSymbol = null; + SymbolLots = new List(); + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Laden der Lots: {ex.Message}"; + } + } + + private async Task LoadPendingAssignmentsAsync() + { + try + { + PendingAssignments = await LotsApi.GetPendingLotAssignmentsAsync(); + } + catch (Exception ex) + { + // Nicht kritisch - nur loggen + Console.WriteLine($"Fehler beim Laden der ausstehenden Zuordnungen: {ex.Message}"); + } + } + + private async Task OnWalletChanged(string? walletName) + { + SelectedWalletName = walletName; + await LoadWalletDataAsync(); + } + + private async Task ShowLotsForSymbol(string symbol) + { + if (string.IsNullOrEmpty(SelectedWalletName)) return; + + SelectedSymbol = symbol; + try + { + SymbolLots = await LotsApi.GetAvailableLotsAsync(SelectedWalletName, symbol); + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Laden der Lots: {ex.Message}"; + } + } + + private void OpenGenerateDialog() + { + IsGenerateDialogOpen = true; + } + + private void CloseGenerateDialog() + { + IsGenerateDialogOpen = false; + } + + private async Task GenerateLots() + { + IsGenerating = true; + ErrorMessage = null; + + try + { + var result = await LotsApi.GenerateLotsFromExistingDataAsync( + new GenerateLotsRequest(FromTrades: true, FromTransactions: false)); + + SuccessMessage = $"{result.LotsCreated} Lots erfolgreich generiert!"; + IsGenerateDialogOpen = false; + + // Daten neu laden + await LoadWalletDataAsync(); + await LoadPendingAssignmentsAsync(); + + // Success-Message nach 5 Sekunden ausblenden + _ = Task.Delay(5000).ContinueWith(_ => + { + SuccessMessage = null; + InvokeAsync(StateHasChanged); + }); + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Generieren: {ex.Message}"; + } + finally + { + IsGenerating = false; + } + } + + private void OpenAssignmentDialog(PendingLotAssignmentDTO item) + { + // TODO: Implementiere Dialog für manuelle Lot-Zuordnung + // Für jetzt zeigen wir eine Info-Meldung + SuccessMessage = $"Lot-Zuordnung für {item.Type} #{item.Id} - Feature in Entwicklung"; + } +} diff --git a/src/CryptoTracker.Client/Pages/Transaktionen.razor b/src/CryptoTracker.Client/Pages/Transaktionen.razor index 2b3b096..c364d93 100644 --- a/src/CryptoTracker.Client/Pages/Transaktionen.razor +++ b/src/CryptoTracker.Client/Pages/Transaktionen.razor @@ -4,6 +4,7 @@ @using CryptoTracker.Shared @inject ITransactionsApi TransactionsApi @inject IWalletApi WalletApi +@inject ILotsApi LotsApi Transaktionen @@ -64,72 +65,66 @@ @FormatHelper.FormatAmount(row.Amount) - - - - - - - - - - - - - - - - - - - + - + + + + + + + + + + - + + + + @@ -142,6 +137,7 @@ } +@* Details Modal *@ @if (IsDetailsOpen) { } + +@* Lot Assignment Modal *@ +@if (IsLotAssignmentOpen && LotAssignmentRow != null) +{ + +} diff --git a/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs b/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs index dc5e260..740a10e 100644 --- a/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs +++ b/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs @@ -21,9 +21,23 @@ public partial class Transaktionen private bool IsDetailsLoading { get; set; } private string? DetailsError { get; set; } private FlowDetailsDTO? Details { get; set; } + private TransactionRowDTO? CurrentRow { get; set; } + + // Lot Assignment State + private IList SelectedLotAllocations { get; set; } = new List(); + private bool IsConfirmingLotAssignment { get; set; } + private string? LotAssignmentError { get; set; } + private string? LotAssignmentSuccess { get; set; } + private bool IsLotAssignmentConfirmed { get; set; } + + // Lot Assignment Modal State + private bool IsLotAssignmentOpen { get; set; } + private TransactionRowDTO? LotAssignmentRow { get; set; } [Inject] public NavigationManager NavigationManager { get; set; } = null!; + private static readonly string[] FiatSymbols = { "EUR", "USD", "CHF", "GBP", "ZEUR", "ZUSD" }; + protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); @@ -162,6 +176,14 @@ private async Task OpenDetailsAsync(TransactionRowDTO row) IsDetailsLoading = true; DetailsError = null; Details = null; + CurrentRow = row; + + // Reset lot assignment state + SelectedLotAllocations = new List(); + LotAssignmentError = null; + LotAssignmentSuccess = null; + IsLotAssignmentConfirmed = false; + try { Details = await TransactionsApi.GetTransactionDetailsAsync(row.FlowType, row.FlowId); @@ -169,6 +191,8 @@ private async Task OpenDetailsAsync(TransactionRowDTO row) { DetailsError = "Keine Details gefunden."; } + // TODO: Check if lot assignment is already confirmed + // This would require extending the FlowDetailsDTO or adding a separate API call } catch (Exception ex) { @@ -182,5 +206,262 @@ private void CloseDetails() IsDetailsOpen = false; Details = null; DetailsError = null; + CurrentRow = null; + SelectedLotAllocations = new List(); + LotAssignmentError = null; + LotAssignmentSuccess = null; + } + + #region Lot Assignment + + private static bool IsFiatSymbol(string symbol) + { + return FiatSymbols.Contains(symbol.ToUpperInvariant()); + } + + /// + /// Determines if a row requires lot assignment (sell trades to fiat or outgoing transfers) + /// + private bool RequiresLotAssignment(TransactionRowDTO row) + { + // Sell trade: Outflow of crypto -> fiat (TargetSymbol is fiat) + if (row.FlowType == FlowType.Trade && + row.FlowDirection == FlowDirection.Outflow && + !string.IsNullOrWhiteSpace(row.TargetSymbol) && + IsFiatSymbol(row.TargetSymbol)) + { + return true; + } + + // Outgoing transfer: Transaction with outflow direction + if (row.FlowType == FlowType.Transaction && + row.FlowDirection == FlowDirection.Outflow && + !IsFiatSymbol(row.Symbol)) + { + return true; + } + + return false; + } + + /// + /// Determines if the row is a sell trade (crypto to fiat) + /// + private bool IsSellTrade(TransactionRowDTO row) + { + return row.FlowType == FlowType.Trade && + row.FlowDirection == FlowDirection.Outflow && + !string.IsNullOrWhiteSpace(row.TargetSymbol) && + IsFiatSymbol(row.TargetSymbol); + } + + /// + /// Gets the wallet name for lot assignment (source wallet for outflows) + /// + private string GetLotAssignmentWallet() + { + if (LotAssignmentRow == null) return string.Empty; + + // For trades, use the source wallet (row.SourceWallet) + // For transactions (transfers), use the source wallet + return LotAssignmentRow.SourceWallet ?? string.Empty; + } + + /// + /// Opens the lot assignment modal for a given transaction row + /// + private Task OpenLotAssignmentAsync(TransactionRowDTO row) + { + LotAssignmentRow = row; + IsLotAssignmentOpen = true; + + // Reset state + SelectedLotAllocations = new List(); + LotAssignmentError = null; + LotAssignmentSuccess = null; + IsLotAssignmentConfirmed = false; + + return Task.CompletedTask; + } + + /// + /// Closes the lot assignment modal + /// + private void CloseLotAssignment() + { + IsLotAssignmentOpen = false; + LotAssignmentRow = null; + SelectedLotAllocations = new List(); + LotAssignmentError = null; + LotAssignmentSuccess = null; + } + + /// + /// Confirms the lot assignment (calls appropriate API based on type) + /// + private async Task ConfirmLotAssignment() + { + if (LotAssignmentRow == null || SelectedLotAllocations.Count == 0) + return; + + if (IsSellTrade(LotAssignmentRow)) + { + await ConfirmSellLotAssignmentInternal(); + } + else + { + await ConfirmTransferLotAssignmentInternal(); + } + } + + private async Task ConfirmSellLotAssignmentInternal() + { + if (LotAssignmentRow == null) return; + + IsConfirmingLotAssignment = true; + LotAssignmentError = null; + LotAssignmentSuccess = null; + + try + { + var salePricePerUnit = LotAssignmentRow.EuroValue / LotAssignmentRow.Amount; + var request = new SellLotsRequest( + LotAssignmentRow.FlowId, + SelectedLotAllocations, + salePricePerUnit); + + var result = await LotsApi.SellLotsAsync(request); + + IsLotAssignmentConfirmed = true; + LotAssignmentSuccess = $"Lot-Zuordnung bestätigt! " + + $"Realisierter Gewinn: {result.TotalRealizedGain:N2}€ " + + $"(steuerfrei: {result.TaxFreeGain:N2}€, " + + $"steuerpflichtig: {result.TaxableGain:N2}€, " + + $"KESt: {result.EstimatedKESt:N2}€)"; + } + catch (Exception ex) + { + LotAssignmentError = $"Fehler beim Speichern: {ex.Message}"; + } + finally + { + IsConfirmingLotAssignment = false; + } } + + private async Task ConfirmTransferLotAssignmentInternal() + { + if (LotAssignmentRow == null) return; + + IsConfirmingLotAssignment = true; + LotAssignmentError = null; + LotAssignmentSuccess = null; + + try + { + // For transfers, we need both the send and receive transaction IDs + var request = new TransferLotsRequest( + LotAssignmentRow.FlowId, // Send transaction ID + LotAssignmentRow.FlowId, // This should be the opposite - extend API later if needed + SelectedLotAllocations); + + var resultLots = await LotsApi.TransferLotsAsync(request); + + IsLotAssignmentConfirmed = true; + LotAssignmentSuccess = $"Lot-Zuordnung bestätigt! {resultLots.Count} Lots wurden auf das Ziel-Wallet übertragen."; + } + catch (Exception ex) + { + LotAssignmentError = $"Fehler beim Speichern: {ex.Message}"; + } + finally + { + IsConfirmingLotAssignment = false; + } + } + + private void OnLotSelectionChanged(IList allocations) + { + SelectedLotAllocations = allocations; + LotAssignmentError = null; + LotAssignmentSuccess = null; + } + + private async Task ConfirmSellLotAssignment() + { + if (CurrentRow == null || Details?.Trade == null || SelectedLotAllocations.Count == 0) + return; + + IsConfirmingLotAssignment = true; + LotAssignmentError = null; + LotAssignmentSuccess = null; + + try + { + var salePricePerUnit = Details.Trade.EuroValue / Details.Trade.Amount; + var request = new SellLotsRequest( + CurrentRow.FlowId, + SelectedLotAllocations, + salePricePerUnit); + + var result = await LotsApi.SellLotsAsync(request); + + IsLotAssignmentConfirmed = true; + LotAssignmentSuccess = $"Lot-Zuordnung bestätigt! " + + $"Realisierter Gewinn: {result.TotalRealizedGain:N2}€ " + + $"(steuerfrei: {result.TaxFreeGain:N2}€, " + + $"steuerpflichtig: {result.TaxableGain:N2}€, " + + $"KESt: {result.EstimatedKESt:N2}€)"; + } + catch (Exception ex) + { + LotAssignmentError = $"Fehler beim Speichern: {ex.Message}"; + } + finally + { + IsConfirmingLotAssignment = false; + } + } + + private async Task ConfirmTransferLotAssignment() + { + if (CurrentRow == null || Details?.Transaction == null || Details.OppositeTransaction == null || SelectedLotAllocations.Count == 0) + return; + + IsConfirmingLotAssignment = true; + LotAssignmentError = null; + LotAssignmentSuccess = null; + + try + { + // For transfers, we need both the send and receive transaction IDs + // The current row is the send transaction, the opposite is the receive + // TODO: The API currently requires transaction IDs, but we have FlowId + // We need to ensure the FlowId corresponds to the correct transaction ID + + var request = new TransferLotsRequest( + CurrentRow.FlowId, // Send transaction ID + CurrentRow.FlowId, // This needs to be the opposite transaction ID - we need to extend the API + SelectedLotAllocations); + + // Note: This simplified implementation assumes CurrentRow.FlowId works for both + // In a real implementation, you'd need to get the opposite transaction ID + // from the Details or extend the API + + var resultLots = await LotsApi.TransferLotsAsync(request); + + IsLotAssignmentConfirmed = true; + LotAssignmentSuccess = $"Lot-Zuordnung bestätigt! {resultLots.Count} Lots wurden auf das Ziel-Wallet übertragen."; + } + catch (Exception ex) + { + LotAssignmentError = $"Fehler beim Speichern: {ex.Message}"; + } + finally + { + IsConfirmingLotAssignment = false; + } + } + + #endregion } diff --git a/src/CryptoTracker.Client/Shared/Api/ILotsApi.cs b/src/CryptoTracker.Client/Shared/Api/ILotsApi.cs new file mode 100644 index 0000000..f8c2ae7 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/Api/ILotsApi.cs @@ -0,0 +1,27 @@ +namespace CryptoTracker.Shared; + +public interface ILotsApi +{ + // Lot-Abfragen + Task> GetAvailableLotsAsync(string walletName, string symbol); + Task> GetAltbestandLotsAsync(string walletName, string symbol); + Task> GetNeubestandLotsAsync(string walletName, string symbol); + Task GetLotByIdAsync(int lotId); + Task> GetLotSummaryByWalletAsync(string walletName); + + // Lot-Erstellung + Task CreateManualLotAsync(CreateManualLotRequest request); + + // Lot-Verwendung + Task> TransferLotsAsync(TransferLotsRequest request); + Task SellLotsAsync(SellLotsRequest request); + + // Auto-FIFO + Task SuggestFifoAllocationAsync(string walletName, string symbol, decimal quantity, bool prioritizeAltbestand); + + // Pending Assignments + Task> GetPendingLotAssignmentsAsync(); + + // Lot-Generierung + Task GenerateLotsFromExistingDataAsync(GenerateLotsRequest request); +} diff --git a/src/CryptoTracker.Client/Shared/LotDTOs.cs b/src/CryptoTracker.Client/Shared/LotDTOs.cs new file mode 100644 index 0000000..b258b36 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LotDTOs.cs @@ -0,0 +1,120 @@ +namespace CryptoTracker.Shared; + +/// +/// DTO für ein Asset-Lot (Tranche). +/// +public record LotDTO( + int Id, + string Symbol, + int WalletId, + string WalletName, + decimal RemainingQuantity, + decimal OriginalQuantity, + DateTimeOffset AcquisitionDate, + decimal AcquisitionPriceEur, + decimal TotalAcquisitionCostEur, + string AcquisitionType, + bool IsAltbestand, + bool IsFullyConsumed, + string? Note, + int? ParentLotId, + int? SourceTradeId, + int? SourceTransactionId); + +/// +/// DTO für Lot-Zusammenfassung pro Symbol. +/// +public record LotSummaryDTO( + string Symbol, + decimal TotalQuantity, + decimal AltbestandQuantity, + decimal NeubestandQuantity, + decimal TotalAcquisitionCostEur, + decimal AverageAcquisitionPrice, + int LotCount); + +/// +/// DTO für eine Lot-Zuordnung (bei Transfer/Verkauf). +/// +public record LotAllocationDTO( + int LotId, + decimal Quantity); + +/// +/// Request für Transfer mit Lot-Zuordnung. +/// +public record TransferLotsRequest( + int SendTransactionId, + int ReceiveTransactionId, + IList Allocations); + +/// +/// Request für Verkauf mit Lot-Zuordnung. +/// +public record SellLotsRequest( + int TradeId, + IList Allocations, + decimal SalePriceEurPerUnit); + +/// +/// Request für manuelle Lot-Erstellung. +/// +public record CreateManualLotRequest( + string Symbol, + int WalletId, + decimal Quantity, + DateTimeOffset AcquisitionDate, + decimal AcquisitionPriceEur, + string AcquisitionType, + int? SourceTransactionId, + string? Note); + +/// +/// Ergebnis eines Verkaufs. +/// +public record SaleResultDTO( + decimal TotalQuantity, + decimal TotalAcquisitionCost, + decimal TotalSaleProceeds, + decimal TotalRealizedGain, + decimal TaxFreeGain, + decimal TaxFreeQuantity, + decimal TaxableGain, + decimal TaxableQuantity, + decimal EstimatedKESt); + +/// +/// FIFO-Vorschlag für eine bestimmte Menge. +/// +public record FifoSuggestionDTO( + IList Allocations, + decimal TotalAllocated, + decimal MissingQuantity, + bool IsComplete); + +/// +/// Transaktionen/Trades die Lot-Zuordnung benötigen. +/// +public record PendingLotAssignmentDTO( + string Type, // "Transaction" oder "Trade" + int Id, + DateTimeOffset DateTime, + string Symbol, + decimal Quantity, + string WalletName, + string? Direction, // "Receive" für Transactions, "Sell" für Trades + string? OppositeWalletName); + +/// +/// Request für Lot-Generierung aus bestehenden Daten. +/// +public record GenerateLotsRequest( + bool FromTrades = true, + bool FromTransactions = false); + +/// +/// Ergebnis der Lot-Generierung. +/// +public record GenerateLotsResultDTO( + int LotsCreated, + int Errors); diff --git a/src/CryptoTracker.Client/Shared/LotSelector.razor b/src/CryptoTracker.Client/Shared/LotSelector.razor new file mode 100644 index 0000000..24abbf2 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LotSelector.razor @@ -0,0 +1,314 @@ +@namespace CryptoTracker.Client.Shared +@using CryptoTracker.Client.Common +@using CryptoTracker.Shared + +
+
+

Lot-Auswahl für @RequiredQuantity @Symbol

+ @if (PrioritizeAltbestand) + { + Altbestand priorisiert + } +
+ + @if (IsLoading) + { +
+
+ Lade Lots... +
+ } + else if (AvailableLots.Count == 0) + { +
+ Keine verfügbaren Lots für @Symbol auf diesem Wallet. +
+ } + else + { +
+ + +
+ +
+ @foreach (var lot in AvailableLots) + { + var allocation = GetAllocation(lot.Id); + var isSelected = allocation > 0; +
+
+
Lot #@lot.Id
+ + @(lot.IsAltbestand ? "ALTBESTAND" : "Neubestand") + +
+
+
+ + @FormatHelper.FormatAmount(lot.RemainingQuantity) @Symbol +
+
+ + @lot.AcquisitionDate.ToString("dd.MM.yyyy") +
+
+ + @FormatHelper.FormatEuro(lot.AcquisitionPriceEur) / @Symbol +
+
+ + @lot.AcquisitionType +
+
+
+ + + @Symbol + +
+
+ } +
+ +
+
+ Ausgewählt: + + @FormatHelper.FormatAmount(TotalAllocated) / @FormatHelper.FormatAmount(RequiredQuantity) @Symbol + +
+ @if (TotalAllocated > 0) + { +
+ Altbestand: + @FormatHelper.FormatAmount(AltbestandAllocated) @Symbol +
+
+ Neubestand: + @FormatHelper.FormatAmount(NeubestandAllocated) @Symbol +
+ @if (ShowTaxPreview && SalePriceEur.HasValue) + { +
+
Steuer-Vorschau
+
+ Gesamter Gewinn: + @FormatHelper.FormatEuro(EstimatedTotalGain) +
+
+ Steuerfrei (Altbestand): + @FormatHelper.FormatEuro(EstimatedTaxFreeGain) +
+
+ Steuerpflichtig: + @FormatHelper.FormatEuro(EstimatedTaxableGain) +
+
+ Geschätzte KESt (27,5%): + @FormatHelper.FormatEuro(EstimatedKESt) +
+
+ } + } + @if (MissingQuantity > 0) + { +
+ Es fehlen noch @FormatHelper.FormatAmount(MissingQuantity) @Symbol! +
+ } +
+ } +
+ +@code { + [Parameter] public string WalletName { get; set; } = string.Empty; + [Parameter] public string Symbol { get; set; } = string.Empty; + [Parameter] public decimal RequiredQuantity { get; set; } + [Parameter] public bool PrioritizeAltbestand { get; set; } = true; + [Parameter] public bool ShowTaxPreview { get; set; } = false; + [Parameter] public decimal? SalePriceEur { get; set; } + [Parameter] public DateTimeOffset? MaxAcquisitionDate { get; set; } + [Parameter] public EventCallback> SelectionChanged { get; set; } + [Parameter] public ILotsApi? LotsApi { get; set; } + + private bool IsLoading { get; set; } = true; + private IList AvailableLots { get; set; } = new List(); + private Dictionary Allocations { get; set; } = new(); + + private decimal TotalAllocated => Allocations.Values.Sum(); + private decimal MissingQuantity => Math.Max(0, RequiredQuantity - TotalAllocated); + + private decimal AltbestandAllocated => Allocations + .Where(kvp => AvailableLots.FirstOrDefault(l => l.Id == kvp.Key)?.IsAltbestand == true) + .Sum(kvp => kvp.Value); + + private decimal NeubestandAllocated => TotalAllocated - AltbestandAllocated; + + // Steuer-Vorschau + private decimal EstimatedTotalGain + { + get + { + if (!SalePriceEur.HasValue) return 0; + return Allocations.Sum(kvp => + { + var lot = AvailableLots.FirstOrDefault(l => l.Id == kvp.Key); + if (lot == null) return 0; + return (SalePriceEur.Value - lot.AcquisitionPriceEur) * kvp.Value; + }); + } + } + + private decimal EstimatedTaxFreeGain + { + get + { + if (!SalePriceEur.HasValue) return 0; + return Allocations + .Where(kvp => AvailableLots.FirstOrDefault(l => l.Id == kvp.Key)?.IsAltbestand == true) + .Sum(kvp => + { + var lot = AvailableLots.First(l => l.Id == kvp.Key); + return (SalePriceEur.Value - lot.AcquisitionPriceEur) * kvp.Value; + }); + } + } + + private decimal EstimatedTaxableGain => EstimatedTotalGain - EstimatedTaxFreeGain; + private decimal EstimatedKESt => EstimatedTaxableGain > 0 ? EstimatedTaxableGain * 0.275m : 0; + + private string? _lastLoadedKey; + private bool _isLoadingLots; + + protected override async Task OnParametersSetAsync() + { + if (LotsApi != null && !string.IsNullOrWhiteSpace(WalletName) && !string.IsNullOrWhiteSpace(Symbol)) + { + // Prevent multiple concurrent loads for the same parameters + var key = $"{WalletName}|{Symbol}|{MaxAcquisitionDate}"; + if (key != _lastLoadedKey && !_isLoadingLots) + { + _lastLoadedKey = key; + await LoadLotsAsync(); + } + } + } + + private async Task LoadLotsAsync() + { + if (_isLoadingLots) return; + _isLoadingLots = true; + IsLoading = true; + try + { + var lots = await LotsApi!.GetAvailableLotsAsync(WalletName, Symbol); + + // Filter by MaxAcquisitionDate if specified (only show lots acquired BEFORE the sale/transfer date) + if (MaxAcquisitionDate.HasValue) + { + lots = lots.Where(l => l.AcquisitionDate < MaxAcquisitionDate.Value).ToList(); + } + + // Sortierung: Altbestand zuerst wenn priorisiert + if (PrioritizeAltbestand) + { + AvailableLots = lots + .OrderByDescending(l => l.IsAltbestand) + .ThenBy(l => l.AcquisitionDate) + .ToList(); + } + else + { + AvailableLots = lots.OrderBy(l => l.AcquisitionDate).ToList(); + } + } + finally + { + IsLoading = false; + _isLoadingLots = false; + } + } + + private decimal GetAllocation(int lotId) + { + return Allocations.TryGetValue(lotId, out var value) ? value : 0; + } + + private async Task SetAllocation(int lotId, decimal quantity) + { + var lot = AvailableLots.FirstOrDefault(l => l.Id == lotId); + if (lot == null) return; + + quantity = Math.Max(0, Math.Min(quantity, lot.RemainingQuantity)); + + if (quantity > 0) + { + Allocations[lotId] = quantity; + } + else + { + Allocations.Remove(lotId); + } + + await NotifySelectionChanged(); + } + + private async Task ApplyFifoSuggestion() + { + if (LotsApi == null) return; + + var suggestion = await LotsApi.SuggestFifoAllocationAsync( + WalletName, Symbol, RequiredQuantity, PrioritizeAltbestand); + + Allocations.Clear(); + + // Only apply allocations for lots that are in our filtered AvailableLots list + var availableLotIds = AvailableLots.Select(l => l.Id).ToHashSet(); + foreach (var alloc in suggestion.Allocations) + { + if (availableLotIds.Contains(alloc.LotId)) + { + Allocations[alloc.LotId] = alloc.Quantity; + } + } + + await NotifySelectionChanged(); + } + + private async Task ClearSelection() + { + Allocations.Clear(); + await NotifySelectionChanged(); + } + + private async Task NotifySelectionChanged() + { + var allocations = Allocations + .Where(kvp => kvp.Value > 0) + .Select(kvp => new LotAllocationDTO(kvp.Key, kvp.Value)) + .ToList(); + + await SelectionChanged.InvokeAsync(allocations); + } + + private static decimal ParseDecimal(object? value) + { + if (value == null) return 0; + if (decimal.TryParse(value.ToString(), out var result)) + return result; + return 0; + } +} diff --git a/src/CryptoTracker/Components/Layout/NavMenu.razor b/src/CryptoTracker/Components/Layout/NavMenu.razor index 5db0791..c242c64 100644 --- a/src/CryptoTracker/Components/Layout/NavMenu.razor +++ b/src/CryptoTracker/Components/Layout/NavMenu.razor @@ -4,6 +4,7 @@ + diff --git a/src/CryptoTracker/Controllers/LotsController.cs b/src/CryptoTracker/Controllers/LotsController.cs new file mode 100644 index 0000000..efc6db5 --- /dev/null +++ b/src/CryptoTracker/Controllers/LotsController.cs @@ -0,0 +1,306 @@ +using CryptoTracker.Entities; +using CryptoTracker.Services; +using CryptoTracker.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace CryptoTracker.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class LotsController : ControllerBase, ILotsApi +{ + private readonly LotService _lotService; + private readonly CryptoTrackerDbContext _dbContext; + + public LotsController(LotService lotService, CryptoTrackerDbContext dbContext) + { + _lotService = lotService; + _dbContext = dbContext; + } + + #region Hilfsmethoden + + private async Task GetWalletIdByNameAsync(string walletName) + { + var wallet = await _dbContext.Wallets + .AsNoTracking() + .FirstOrDefaultAsync(w => w.Name == walletName); + return wallet?.Id; + } + + #endregion + + #region Lot-Abfragen + + [HttpGet("GetAvailableLots")] + public async Task> GetAvailableLots([FromQuery] string walletName, [FromQuery] string symbol) + { + var walletId = await GetWalletIdByNameAsync(walletName); + if (walletId == null) return new List(); + + var lots = await _lotService.GetAvailableLotsAsync(walletId.Value, symbol); + return lots.Select(MapToDTO).ToList(); + } + + [HttpGet("GetAltbestandLots")] + public async Task> GetAltbestandLots([FromQuery] string walletName, [FromQuery] string symbol) + { + var walletId = await GetWalletIdByNameAsync(walletName); + if (walletId == null) return new List(); + + var lots = await _lotService.GetAltbestandLotsAsync(walletId.Value, symbol); + return lots.Select(MapToDTO).ToList(); + } + + [HttpGet("GetNeubestandLots")] + public async Task> GetNeubestandLots([FromQuery] string walletName, [FromQuery] string symbol) + { + var walletId = await GetWalletIdByNameAsync(walletName); + if (walletId == null) return new List(); + + var lots = await _lotService.GetNeubestandLotsAsync(walletId.Value, symbol); + return lots.Select(MapToDTO).ToList(); + } + + [HttpGet("GetLotById")] + public async Task GetLotById([FromQuery] int lotId) + { + var lot = await _lotService.GetLotByIdAsync(lotId); + return lot != null ? MapToDTO(lot) : null; + } + + [HttpGet("GetLotSummaryByWallet")] + public async Task> GetLotSummaryByWallet([FromQuery] string walletName) + { + var walletId = await GetWalletIdByNameAsync(walletName); + if (walletId == null) return new Dictionary(); + + var summary = await _lotService.GetLotSummaryByWalletAsync(walletId.Value); + return summary.ToDictionary( + kvp => kvp.Key, + kvp => new LotSummaryDTO( + kvp.Value.Symbol, + kvp.Value.TotalQuantity, + kvp.Value.AltbestandQuantity, + kvp.Value.NeubestandQuantity, + kvp.Value.TotalAcquisitionCostEur, + kvp.Value.AverageAcquisitionPrice, + kvp.Value.LotCount)); + } + + #endregion + + #region Lot-Erstellung + + [HttpPost("CreateManualLot")] + public async Task CreateManualLot([FromBody] CreateManualLotRequest request) + { + var lot = await _lotService.CreateManualLotAsync(new ManualLotRequest + { + Symbol = request.Symbol, + WalletId = request.WalletId, + Quantity = request.Quantity, + AcquisitionDate = request.AcquisitionDate, + AcquisitionPriceEur = request.AcquisitionPriceEur, + AcquisitionType = Enum.Parse(request.AcquisitionType), + SourceTransactionId = request.SourceTransactionId, + Note = request.Note + }); + return MapToDTO(lot); + } + + #endregion + + #region Lot-Verwendung + + [HttpPost("TransferLots")] + public async Task> TransferLots([FromBody] TransferLotsRequest request) + { + var allocations = request.Allocations + .Select(a => new LotAllocation { LotId = a.LotId, Quantity = a.Quantity }) + .ToList(); + + var lots = await _lotService.TransferLotsAsync( + request.SendTransactionId, + request.ReceiveTransactionId, + allocations); + + return lots.Select(MapToDTO).ToList(); + } + + [HttpPost("SellLots")] + public async Task SellLots([FromBody] SellLotsRequest request) + { + var allocations = request.Allocations + .Select(a => new LotAllocation { LotId = a.LotId, Quantity = a.Quantity }) + .ToList(); + + var result = await _lotService.SellLotsAsync( + request.TradeId, + allocations, + request.SalePriceEurPerUnit); + + return new SaleResultDTO( + result.TotalQuantity, + result.TotalAcquisitionCost, + result.TotalSaleProceeds, + result.TotalRealizedGain, + result.TaxFreeGain, + result.TaxFreeQuantity, + result.TaxableGain, + result.TaxableQuantity, + result.EstimatedKESt); + } + + #endregion + + #region Auto-FIFO + + [HttpGet("SuggestFifoAllocation")] + public async Task SuggestFifoAllocation( + [FromQuery] string walletName, + [FromQuery] string symbol, + [FromQuery] decimal quantity, + [FromQuery] bool prioritizeAltbestand = false) + { + var walletId = await GetWalletIdByNameAsync(walletName); + if (walletId == null) + { + return new FifoSuggestionDTO(new List(), 0, quantity, false); + } + + var allocations = await _lotService.SuggestFifoAllocationAsync( + walletId.Value, symbol, quantity, prioritizeAltbestand); + + var totalAllocated = allocations.Sum(a => a.Quantity); + var missing = quantity - totalAllocated; + + return new FifoSuggestionDTO( + allocations.Select(a => new LotAllocationDTO(a.LotId, a.Quantity)).ToList(), + totalAllocated, + missing > 0 ? missing : 0, + missing <= 0); + } + + #endregion + + #region Pending Assignments + + [HttpGet("GetPendingLotAssignments")] + public async Task> GetPendingLotAssignments() + { + var transactions = await _lotService.GetTransactionsRequiringLotAssignmentAsync(); + var trades = await _lotService.GetTradesRequiringLotAssignmentAsync(); + + var result = new List(); + + result.AddRange(transactions.Select(t => new PendingLotAssignmentDTO( + "Transaction", + t.Id, + t.DateTime, + t.Symbol, + t.Quantity, + t.Wallet.Name, + t.TransactionType.ToString(), + t.OppositeWallet?.Name))); + + result.AddRange(trades.Select(t => new PendingLotAssignmentDTO( + "Trade", + t.Id, + t.DateTime, + t.Symbol, + t.Quantity, + t.Wallet.Name, + t.TradeType.ToString(), + null))); + + return result.OrderBy(p => p.DateTime).ToList(); + } + + #endregion + + #region Lot-Generierung + + [HttpPost("GenerateLotsFromExistingData")] + public async Task GenerateLotsFromExistingData([FromBody] GenerateLotsRequest request) + { + var count = 0; + var errors = 0; + + if (request.FromTrades) + { + try + { + count += await _lotService.GenerateLotsFromExistingTradesAsync(); + } + catch + { + errors++; + } + } + + return new GenerateLotsResultDTO(count, errors); + } + + #endregion + + #region ILotsApi Implementation + + Task> ILotsApi.GetAvailableLotsAsync(string walletName, string symbol) + => GetAvailableLots(walletName, symbol); + + Task> ILotsApi.GetAltbestandLotsAsync(string walletName, string symbol) + => GetAltbestandLots(walletName, symbol); + + Task> ILotsApi.GetNeubestandLotsAsync(string walletName, string symbol) + => GetNeubestandLots(walletName, symbol); + + Task ILotsApi.GetLotByIdAsync(int lotId) + => GetLotById(lotId); + + Task> ILotsApi.GetLotSummaryByWalletAsync(string walletName) + => GetLotSummaryByWallet(walletName); + + Task ILotsApi.CreateManualLotAsync(CreateManualLotRequest request) + => CreateManualLot(request); + + Task> ILotsApi.TransferLotsAsync(TransferLotsRequest request) + => TransferLots(request); + + Task ILotsApi.SellLotsAsync(SellLotsRequest request) + => SellLots(request); + + Task ILotsApi.SuggestFifoAllocationAsync(string walletName, string symbol, decimal quantity, bool prioritizeAltbestand) + => SuggestFifoAllocation(walletName, symbol, quantity, prioritizeAltbestand); + + Task> ILotsApi.GetPendingLotAssignmentsAsync() + => GetPendingLotAssignments(); + + Task ILotsApi.GenerateLotsFromExistingDataAsync(GenerateLotsRequest request) + => GenerateLotsFromExistingData(request); + + #endregion + + #region Mapping + + private static LotDTO MapToDTO(AssetLot lot) => new( + lot.Id, + lot.Symbol, + lot.CurrentWalletId, + lot.CurrentWallet?.Name ?? string.Empty, + lot.RemainingQuantity, + lot.OriginalQuantity, + lot.AcquisitionDate, + lot.AcquisitionPriceEur, + lot.TotalAcquisitionCostEur, + lot.AcquisitionType.ToString(), + lot.IsAltbestand, + lot.IsFullyConsumed, + lot.Note, + lot.ParentLotId, + lot.SourceTradeId, + lot.SourceTransactionId); + + #endregion +} diff --git a/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs b/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs index 9303358..677f770 100644 --- a/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs +++ b/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs @@ -1,4 +1,4 @@ -using CryptoTracker.Entities; +using CryptoTracker.Entities; using CryptoTracker.Entities.Import; using Microsoft.EntityFrameworkCore; @@ -20,6 +20,8 @@ public class CryptoTrackerDbContext : DbContext public DbSet OkxDeposits { get; set; } public DbSet OkxTrades { get; set; } public DbSet ManualCoinPrices { get; set; } + public DbSet AssetLots { get; set; } + public DbSet LotMovements { get; set; } public CryptoTrackerDbContext(DbContextOptions options) : base(options) { @@ -104,6 +106,93 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(p => p.Date) .HasColumnType("date"); + // === AssetLot Configuration === + modelBuilder.Entity().HasKey(l => l.Id); + modelBuilder.Entity() + .HasOne(l => l.CurrentWallet) + .WithMany() + .HasForeignKey(l => l.CurrentWalletId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(l => l.SourceTransaction) + .WithMany() + .HasForeignKey(l => l.SourceTransactionId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(l => l.SourceTrade) + .WithMany() + .HasForeignKey(l => l.SourceTradeId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(l => l.ParentLot) + .WithMany(l => l.ChildLots) + .HasForeignKey(l => l.ParentLotId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasIndex(l => new { l.CurrentWalletId, l.Symbol }); + modelBuilder.Entity() + .HasIndex(l => l.AcquisitionDate); + modelBuilder.Entity() + .Property(l => l.RemainingQuantity) + .HasColumnType("decimal(27, 12)"); + modelBuilder.Entity() + .Property(l => l.OriginalQuantity) + .HasColumnType("decimal(27, 12)"); + modelBuilder.Entity() + .Property(l => l.AcquisitionPriceEur) + .HasColumnType("decimal(27, 12)"); + modelBuilder.Entity() + .Property(l => l.TotalAcquisitionCostEur) + .HasColumnType("decimal(27, 12)"); + + // === LotMovement Configuration === + modelBuilder.Entity().HasKey(m => m.Id); + modelBuilder.Entity() + .HasOne(m => m.Lot) + .WithMany(l => l.Movements) + .HasForeignKey(m => m.LotId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(m => m.Trade) + .WithMany(t => t.LotMovements) + .HasForeignKey(m => m.TradeId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(m => m.Transaction) + .WithMany(t => t.LotMovements) + .HasForeignKey(m => m.TransactionId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(m => m.ResultingLot) + .WithMany() + .HasForeignKey(m => m.ResultingLotId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasIndex(m => m.DateTime); + modelBuilder.Entity() + .Property(m => m.Quantity) + .HasColumnType("decimal(27, 12)"); + modelBuilder.Entity() + .Property(m => m.SalePriceEur) + .HasColumnType("decimal(27, 12)"); + modelBuilder.Entity() + .Property(m => m.RealizedGainEur) + .HasColumnType("decimal(27, 12)"); + + // === CryptoTransaction Lot References === + modelBuilder.Entity() + .HasOne(t => t.ResultingLot) + .WithMany() + .HasForeignKey(t => t.ResultingLotId) + .OnDelete(DeleteBehavior.Restrict); + + // === CryptoTrade Lot References === + modelBuilder.Entity() + .HasOne(t => t.ResultingLot) + .WithMany() + .HasForeignKey(t => t.ResultingLotId) + .OnDelete(DeleteBehavior.Restrict); + base.OnModelCreating(modelBuilder); } } diff --git a/src/CryptoTracker/Entities/AssetLot.cs b/src/CryptoTracker/Entities/AssetLot.cs new file mode 100644 index 0000000..40bd214 --- /dev/null +++ b/src/CryptoTracker/Entities/AssetLot.cs @@ -0,0 +1,155 @@ +namespace CryptoTracker.Entities; + +/// +/// Repräsentiert eine spezifische Tranche eines Assets mit eindeutiger Herkunft. +/// Jedes Lot hat ein Kaufdatum, Kaufpreis und kann als Altbestand (vor 28.02.2021) markiert sein. +/// +public class AssetLot +{ + public int Id { get; set; } + + /// + /// Welches Asset (z.B. BTC, ETH) + /// + public string Symbol { get; set; } = string.Empty; + + /// + /// Wo liegt das Lot aktuell? + /// + public int CurrentWalletId { get; set; } + public Wallet CurrentWallet { get; set; } = null!; + + /// + /// Aktuelle Menge (kann durch Teil-Verkäufe/Transfers abnehmen) + /// + public decimal RemainingQuantity { get; set; } + + /// + /// Ursprüngliche Menge bei Erstellung des Lots + /// + public decimal OriginalQuantity { get; set; } + + // === Herkunftsinformationen === + + /// + /// Wann wurde dieses Lot ursprünglich erworben? + /// Entscheidend für Altbestand/Neubestand-Klassifizierung + /// + public DateTimeOffset AcquisitionDate { get; set; } + + /// + /// Anschaffungskosten pro Einheit in EUR + /// + public decimal AcquisitionPriceEur { get; set; } + + /// + /// Gesamte Anschaffungskosten (inkl. Gebühren) in EUR + /// + public decimal TotalAcquisitionCostEur { get; set; } + + /// + /// Wie wurde das Lot erworben? + /// + public LotAcquisitionType AcquisitionType { get; set; } + + /// + /// Referenz zur ursprünglichen Transaktion (bei Receive/Transfer) + /// + public int? SourceTransactionId { get; set; } + public CryptoTransaction? SourceTransaction { get; set; } + + /// + /// Referenz zum ursprünglichen Trade (bei Kauf) + /// + public int? SourceTradeId { get; set; } + public CryptoTrade? SourceTrade { get; set; } + + /// + /// Bei Transfer oder Krypto-zu-Krypto-Tausch: Lot des Quell-Assets. + /// Ermöglicht Rückverfolgung der Anschaffungskosten. + /// + public int? ParentLotId { get; set; } + public AssetLot? ParentLot { get; set; } + + /// + /// Child-Lots die aus diesem Lot entstanden sind (z.B. bei Teil-Transfer) + /// + public ICollection ChildLots { get; set; } = new List(); + + /// + /// Bewegungen (Verwendungen) dieses Lots + /// + public ICollection Movements { get; set; } = new List(); + + /// + /// Benutzernotiz zur Herkunft + /// + public string? Note { get; set; } + + /// + /// Zeitpunkt der Erstellung dieses Lot-Eintrags + /// + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + // === Berechnete Properties === + + /// + /// Ist Altbestand (erworben bis einschließlich 28.02.2021)? + /// Nach der ökosozialen Steuerreform: Altbestand mit >1 Jahr Haltefrist ist steuerfrei. + /// + public bool IsAltbestand => AcquisitionDate <= AltbestandStichtag; + + /// + /// Stichtag für Altbestand-Klassifizierung (28.02.2021 23:59:59 UTC) + /// + public static readonly DateTimeOffset AltbestandStichtag = new(2021, 2, 28, 23, 59, 59, TimeSpan.Zero); + + /// + /// Ist dieses Lot vollständig aufgebraucht? + /// + public bool IsFullyConsumed => RemainingQuantity <= 0; + + /// + /// Anschaffungskosten für die verbleibende Menge + /// + public decimal RemainingAcquisitionCostEur => RemainingQuantity * AcquisitionPriceEur; +} + +/// +/// Art der Akquisition eines Lots +/// +public enum LotAcquisitionType +{ + /// Kauf mit Fiat (EUR, USD etc.) + FiatPurchase, + + /// Erhalt durch Krypto-zu-Krypto-Tausch + CryptoSwap, + + /// Transfer von eigener Börse/Wallet (Herkunft bekannt, verknüpft) + InternalTransfer, + + /// Transfer von externer Quelle (Herkunft muss dokumentiert werden) + ExternalDeposit, + + /// Mining-Rewards + Mining, + + /// Staking-Rewards + Staking, + + /// Lending-Zinsen + Lending, + + /// Airdrop + Airdrop, + + /// Hardfork + Hardfork, + + /// Schenkung erhalten + Gift, + + /// Manueller Eintrag (z.B. für Altbestand-Import) + Manual +} diff --git a/src/CryptoTracker/Entities/CryptoTrade.cs b/src/CryptoTracker/Entities/CryptoTrade.cs index fe48cd6..38b353d 100644 --- a/src/CryptoTracker/Entities/CryptoTrade.cs +++ b/src/CryptoTracker/Entities/CryptoTrade.cs @@ -1,4 +1,4 @@ -using CryptoTracker.Shared; +using CryptoTracker.Shared; namespace CryptoTracker.Entities; @@ -58,6 +58,26 @@ public class CryptoTrade : IFlow /// public CryptoTrade? OppositeTrade { get; set; } + // === Lot-Tracking === + + /// + /// Bei Sell: Welche Lots wurden verkauft? + /// Bei Buy mit Krypto-Zahlung: Welche Lots wurden verwendet? + /// + public ICollection LotMovements { get; set; } = new List(); + + /// + /// Bei Buy: Das erstellte Lot + /// + public int? ResultingLotId { get; set; } + public AssetLot? ResultingLot { get; set; } + + /// + /// Wurde die Lot-Zuordnung für diesen Trade bestätigt? + /// Bei Fiat-Kauf automatisch true, bei Verkauf/Swap muss User Lots auswählen. + /// + public bool LotAssignmentConfirmed { get; set; } + FlowDirection IFlow.FlowDirection => TradeType switch { TradeType.Buy => FlowDirection.Inflow, diff --git a/src/CryptoTracker/Entities/CryptoTransaction.cs b/src/CryptoTracker/Entities/CryptoTransaction.cs index 0ca9eb9..879675c 100644 --- a/src/CryptoTracker/Entities/CryptoTransaction.cs +++ b/src/CryptoTracker/Entities/CryptoTransaction.cs @@ -1,4 +1,4 @@ -using CryptoTracker.Shared; +using CryptoTracker.Shared; namespace CryptoTracker.Entities; @@ -53,6 +53,34 @@ public class CryptoTransaction : IFlow public string? Network { get; set; } public string? Comment { get; set; } + // === Lot-Tracking === + + /// + /// Bei Send: Welche Lots wurden für diese Transaktion verwendet? + /// Bei Receive: Welches Lot wurde erstellt? + /// + public ICollection LotMovements { get; set; } = new List(); + + /// + /// Bei Receive: Das erstellte Lot (falls zugeordnet) + /// + public int? ResultingLotId { get; set; } + public AssetLot? ResultingLot { get; set; } + + /// + /// Wurde die Lot-Zuordnung für diese Transaktion bestätigt? + /// + public bool LotAssignmentConfirmed { get; set; } + + /// + /// Benötigt manuelle Lot-Zuordnung? + /// Bei Receive ohne verknüpfte Gegentransaktion muss Herkunft dokumentiert werden. + /// + public bool RequiresLotAssignment => + TransactionType == TransactionType.Receive && + OppositeTransactionId == null && + !LotAssignmentConfirmed; + FlowDirection IFlow.FlowDirection => TransactionType switch { TransactionType.Receive => FlowDirection.Inflow, diff --git a/src/CryptoTracker/Entities/LotMovement.cs b/src/CryptoTracker/Entities/LotMovement.cs new file mode 100644 index 0000000..ddba4d6 --- /dev/null +++ b/src/CryptoTracker/Entities/LotMovement.cs @@ -0,0 +1,124 @@ +namespace CryptoTracker.Entities; + +/// +/// Dokumentiert jede Verwendung eines Lots (Transfer, Verkauf, Swap, etc.) +/// +public class LotMovement +{ + public int Id { get; set; } + + /// + /// Welches Lot wurde verwendet? + /// + public int LotId { get; set; } + public AssetLot Lot { get; set; } = null!; + + /// + /// Wieviel wurde von diesem Lot verwendet? + /// + public decimal Quantity { get; set; } + + /// + /// Zeitpunkt der Bewegung + /// + public DateTimeOffset DateTime { get; set; } + + /// + /// Art der Bewegung + /// + public LotMovementType MovementType { get; set; } + + // === Referenzen zur auslösenden Aktion === + + /// + /// Referenz zum auslösenden Trade (bei Verkauf/Swap) + /// + public int? TradeId { get; set; } + public CryptoTrade? Trade { get; set; } + + /// + /// Referenz zur auslösenden Transaktion (bei Transfer) + /// + public int? TransactionId { get; set; } + public CryptoTransaction? Transaction { get; set; } + + // === Bei Verkauf: Steuer-relevante Daten === + + /// + /// Bei Verkauf: Erlös pro Einheit in EUR + /// + public decimal? SalePriceEur { get; set; } + + /// + /// Bei Verkauf: Realisierter Gewinn/Verlust in EUR + /// Berechnung: (SalePriceEur - AcquisitionPriceEur) * Quantity + /// + public decimal? RealizedGainEur { get; set; } + + /// + /// War diese Bewegung steuerfrei? (Altbestand oder Krypto-zu-Krypto) + /// + public bool IsTaxFree { get; set; } + + /// + /// Grund für Steuerfreiheit (falls IsTaxFree = true) + /// + public TaxFreeReason? TaxFreeReason { get; set; } + + // === Bei Transfer: Referenz zum resultierenden Lot === + + /// + /// Bei Transfer: Neues Lot auf Ziel-Wallet + /// + public int? ResultingLotId { get; set; } + public AssetLot? ResultingLot { get; set; } + + /// + /// Zeitpunkt der Erstellung dieses Movement-Eintrags + /// + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + /// + /// Benutzernotiz + /// + public string? Note { get; set; } +} + +/// +/// Art der Lot-Bewegung +/// +public enum LotMovementType +{ + /// Transfer zu anderem Wallet (gleiches Asset) + Transfer, + + /// Verkauf gegen Fiat + FiatSale, + + /// Verwendung in Krypto-zu-Krypto-Tausch (als Ausgabe) + CryptoSwapOut, + + /// Gebühr bezahlt + Fee, + + /// Schenkung gegeben + GiftOut, + + /// Verlust (z.B. durch Hack, verlorene Keys) + Loss +} + +/// +/// Grund für Steuerfreiheit einer Bewegung +/// +public enum TaxFreeReason +{ + /// Altbestand (vor 28.02.2021 erworben, >1 Jahr gehalten) + Altbestand, + + /// Krypto-zu-Krypto-Tausch (steuerneutral, Anschaffungskosten werden weitergegeben) + CryptoSwap, + + /// Transfer zwischen eigenen Wallets + InternalTransfer +} diff --git a/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.Designer.cs b/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.Designer.cs new file mode 100644 index 0000000..7f509b6 --- /dev/null +++ b/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.Designer.cs @@ -0,0 +1,1107 @@ +// +using System; +using CryptoTracker; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CryptoTracker.Migrations +{ + [DbContext(typeof(CryptoTrackerDbContext))] + [Migration("20260130220034_AddAssetLotTracking")] + partial class AddAssetLotTracking + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcquisitionDate") + .HasColumnType("datetimeoffset"); + + b.Property("AcquisitionPriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("AcquisitionType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CurrentWalletId") + .HasColumnType("int"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalQuantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("ParentLotId") + .HasColumnType("int"); + + b.Property("RemainingQuantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("SourceTradeId") + .HasColumnType("int"); + + b.Property("SourceTransactionId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TotalAcquisitionCostEur") + .HasColumnType("decimal(27, 12)"); + + b.HasKey("Id"); + + b.HasIndex("AcquisitionDate"); + + b.HasIndex("ParentLotId"); + + b.HasIndex("SourceTradeId"); + + b.HasIndex("SourceTransactionId"); + + b.HasIndex("CurrentWalletId", "Symbol"); + + b.ToTable("AssetLots"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Comment") + .HasColumnType("nvarchar(max)"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("Fee") + .HasColumnType("decimal(27, 12)"); + + b.Property("ForeignFee") + .HasColumnType("decimal(27, 12)"); + + b.Property("ForeignFeeSymbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + + b.Property("OppositeSymbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OppositeTradeId") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("decimal(27, 12)"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("Referenz") + .HasColumnType("nvarchar(max)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TradeType") + .HasColumnType("int"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OppositeTradeId") + .IsUnique() + .HasFilter("[OppositeTradeId] IS NOT NULL"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("WalletId"); + + b.ToTable("CryptoTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasColumnType("nvarchar(max)"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("Fee") + .HasColumnType("decimal(27, 12)"); + + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + + b.Property("Network") + .HasColumnType("nvarchar(max)"); + + b.Property("OppositeTransactionId") + .HasColumnType("int"); + + b.Property("OppositeWalletId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionId") + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .HasColumnType("int"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OppositeTransactionId") + .IsUnique() + .HasFilter("[OppositeTransactionId] IS NOT NULL"); + + b.HasIndex("OppositeWalletId"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("WalletId"); + + b.ToTable("CryptoTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TXID") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BinanceDeposits"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Executed") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fee") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pair") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.Property("Side") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BinanceTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TXID") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BinanceWithdrawals"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Adresse") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CryptoNachGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("CryptoVorGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("Datum") + .HasColumnType("datetimeoffset"); + + b.Property("EinheitKurs") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EinheitMengeNachGebuehr") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EinheitMengeVorGebuehr") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Kommentar") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Kontostand") + .HasColumnType("decimal(18,2)"); + + b.Property("Kurs") + .HasColumnType("decimal(18,2)"); + + b.Property("MengeNachGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("MengeVorGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("Referenz") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Typ") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Waehrung") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.Property("ZuAbgang") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BitcoinDeTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AmountAsset") + .HasColumnType("decimal(18,2)"); + + b.Property("AmountFiat") + .HasColumnType("decimal(18,2)"); + + b.Property("Asset") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AssetClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AssetMarketPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("AssetMarketPriceCurrency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fee") + .HasColumnType("decimal(18,2)"); + + b.Property("FeeAsset") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fiat") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InOut") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProductID") + .HasColumnType("int"); + + b.Property("Spread") + .HasColumnType("decimal(18,2)"); + + b.Property("SpreadCurrency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TaxFiat") + .HasColumnType("decimal(18,2)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("TransactionId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BitpandaTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Datum") + .HasColumnType("datetimeoffset"); + + b.Property("Kommentar") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("Typ") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("LedgerTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Executed") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fee") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pair") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Side") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Tradingplatform") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("MetamaskTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Datum") + .HasColumnType("datetimeoffset"); + + b.Property("Kommentar") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("Typ") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("MetamaskTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TXID") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("OkxDeposits"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Executed") + .HasColumnType("decimal(18,2)"); + + b.Property("Pair") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Side") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("OkxTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("IsTaxFree") + .HasColumnType("bit"); + + b.Property("LotId") + .HasColumnType("int"); + + b.Property("MovementType") + .HasColumnType("int"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("RealizedGainEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("SalePriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("TaxFreeReason") + .HasColumnType("int"); + + b.Property("TradeId") + .HasColumnType("int"); + + b.Property("TransactionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DateTime"); + + b.HasIndex("LotId"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("TradeId"); + + b.HasIndex("TransactionId"); + + b.ToTable("LotMovements"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.ManualCoinPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("PriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Symbol", "Date") + .IsUnique(); + + b.ToTable("ManualCoinPrices"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "CurrentWallet") + .WithMany() + .HasForeignKey("CurrentWalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ParentLot") + .WithMany("ChildLots") + .HasForeignKey("ParentLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTrade", "SourceTrade") + .WithMany() + .HasForeignKey("SourceTradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "SourceTransaction") + .WithMany() + .HasForeignKey("SourceTransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CurrentWallet"); + + b.Navigation("ParentLot"); + + b.Navigation("SourceTrade"); + + b.Navigation("SourceTransaction"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.HasOne("CryptoTracker.Entities.CryptoTrade", "OppositeTrade") + .WithOne() + .HasForeignKey("CryptoTracker.Entities.CryptoTrade", "OppositeTradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OppositeTrade"); + + b.Navigation("ResultingLot"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "OppositeTransaction") + .WithOne() + .HasForeignKey("CryptoTracker.Entities.CryptoTransaction", "OppositeTransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.Wallet", "OppositeWallet") + .WithMany() + .HasForeignKey("OppositeWalletId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OppositeTransaction"); + + b.Navigation("OppositeWallet"); + + b.Navigation("ResultingLot"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => + { + b.HasOne("CryptoTracker.Entities.AssetLot", "Lot") + .WithMany("Movements") + .HasForeignKey("LotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTrade", "Trade") + .WithMany("LotMovements") + .HasForeignKey("TradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "Transaction") + .WithMany("LotMovements") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Lot"); + + b.Navigation("ResultingLot"); + + b.Navigation("Trade"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.Navigation("ChildLots"); + + b.Navigation("Movements"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.Navigation("LotMovements"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.Navigation("LotMovements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.cs b/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.cs new file mode 100644 index 0000000..1ea5b89 --- /dev/null +++ b/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.cs @@ -0,0 +1,257 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CryptoTracker.Migrations +{ + /// + public partial class AddAssetLotTracking : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LotAssignmentConfirmed", + table: "CryptoTransactions", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ResultingLotId", + table: "CryptoTransactions", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "LotAssignmentConfirmed", + table: "CryptoTrades", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ResultingLotId", + table: "CryptoTrades", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "AssetLots", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Symbol = table.Column(type: "nvarchar(450)", nullable: false), + CurrentWalletId = table.Column(type: "int", nullable: false), + RemainingQuantity = table.Column(type: "decimal(27,12)", nullable: false), + OriginalQuantity = table.Column(type: "decimal(27,12)", nullable: false), + AcquisitionDate = table.Column(type: "datetimeoffset", nullable: false), + AcquisitionPriceEur = table.Column(type: "decimal(27,12)", nullable: false), + TotalAcquisitionCostEur = table.Column(type: "decimal(27,12)", nullable: false), + AcquisitionType = table.Column(type: "int", nullable: false), + SourceTransactionId = table.Column(type: "int", nullable: true), + SourceTradeId = table.Column(type: "int", nullable: true), + ParentLotId = table.Column(type: "int", nullable: true), + Note = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AssetLots", x => x.Id); + table.ForeignKey( + name: "FK_AssetLots_AssetLots_ParentLotId", + column: x => x.ParentLotId, + principalTable: "AssetLots", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_AssetLots_CryptoTrades_SourceTradeId", + column: x => x.SourceTradeId, + principalTable: "CryptoTrades", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_AssetLots_CryptoTransactions_SourceTransactionId", + column: x => x.SourceTransactionId, + principalTable: "CryptoTransactions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_AssetLots_Wallets_CurrentWalletId", + column: x => x.CurrentWalletId, + principalTable: "Wallets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "LotMovements", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + LotId = table.Column(type: "int", nullable: false), + Quantity = table.Column(type: "decimal(27,12)", nullable: false), + DateTime = table.Column(type: "datetimeoffset", nullable: false), + MovementType = table.Column(type: "int", nullable: false), + TradeId = table.Column(type: "int", nullable: true), + TransactionId = table.Column(type: "int", nullable: true), + SalePriceEur = table.Column(type: "decimal(27,12)", nullable: true), + RealizedGainEur = table.Column(type: "decimal(27,12)", nullable: true), + IsTaxFree = table.Column(type: "bit", nullable: false), + TaxFreeReason = table.Column(type: "int", nullable: true), + ResultingLotId = table.Column(type: "int", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + Note = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_LotMovements", x => x.Id); + table.ForeignKey( + name: "FK_LotMovements_AssetLots_LotId", + column: x => x.LotId, + principalTable: "AssetLots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_LotMovements_AssetLots_ResultingLotId", + column: x => x.ResultingLotId, + principalTable: "AssetLots", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_LotMovements_CryptoTrades_TradeId", + column: x => x.TradeId, + principalTable: "CryptoTrades", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_LotMovements_CryptoTransactions_TransactionId", + column: x => x.TransactionId, + principalTable: "CryptoTransactions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_CryptoTransactions_ResultingLotId", + table: "CryptoTransactions", + column: "ResultingLotId"); + + migrationBuilder.CreateIndex( + name: "IX_CryptoTrades_ResultingLotId", + table: "CryptoTrades", + column: "ResultingLotId"); + + migrationBuilder.CreateIndex( + name: "IX_AssetLots_AcquisitionDate", + table: "AssetLots", + column: "AcquisitionDate"); + + migrationBuilder.CreateIndex( + name: "IX_AssetLots_CurrentWalletId_Symbol", + table: "AssetLots", + columns: new[] { "CurrentWalletId", "Symbol" }); + + migrationBuilder.CreateIndex( + name: "IX_AssetLots_ParentLotId", + table: "AssetLots", + column: "ParentLotId"); + + migrationBuilder.CreateIndex( + name: "IX_AssetLots_SourceTradeId", + table: "AssetLots", + column: "SourceTradeId"); + + migrationBuilder.CreateIndex( + name: "IX_AssetLots_SourceTransactionId", + table: "AssetLots", + column: "SourceTransactionId"); + + migrationBuilder.CreateIndex( + name: "IX_LotMovements_DateTime", + table: "LotMovements", + column: "DateTime"); + + migrationBuilder.CreateIndex( + name: "IX_LotMovements_LotId", + table: "LotMovements", + column: "LotId"); + + migrationBuilder.CreateIndex( + name: "IX_LotMovements_ResultingLotId", + table: "LotMovements", + column: "ResultingLotId"); + + migrationBuilder.CreateIndex( + name: "IX_LotMovements_TradeId", + table: "LotMovements", + column: "TradeId"); + + migrationBuilder.CreateIndex( + name: "IX_LotMovements_TransactionId", + table: "LotMovements", + column: "TransactionId"); + + migrationBuilder.AddForeignKey( + name: "FK_CryptoTrades_AssetLots_ResultingLotId", + table: "CryptoTrades", + column: "ResultingLotId", + principalTable: "AssetLots", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_CryptoTransactions_AssetLots_ResultingLotId", + table: "CryptoTransactions", + column: "ResultingLotId", + principalTable: "AssetLots", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_CryptoTrades_AssetLots_ResultingLotId", + table: "CryptoTrades"); + + migrationBuilder.DropForeignKey( + name: "FK_CryptoTransactions_AssetLots_ResultingLotId", + table: "CryptoTransactions"); + + migrationBuilder.DropTable( + name: "LotMovements"); + + migrationBuilder.DropTable( + name: "AssetLots"); + + migrationBuilder.DropIndex( + name: "IX_CryptoTransactions_ResultingLotId", + table: "CryptoTransactions"); + + migrationBuilder.DropIndex( + name: "IX_CryptoTrades_ResultingLotId", + table: "CryptoTrades"); + + migrationBuilder.DropColumn( + name: "LotAssignmentConfirmed", + table: "CryptoTransactions"); + + migrationBuilder.DropColumn( + name: "ResultingLotId", + table: "CryptoTransactions"); + + migrationBuilder.DropColumn( + name: "LotAssignmentConfirmed", + table: "CryptoTrades"); + + migrationBuilder.DropColumn( + name: "ResultingLotId", + table: "CryptoTrades"); + } + } +} diff --git a/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs b/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs index ba4de1b..b49b0e7 100644 --- a/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs +++ b/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs @@ -22,6 +22,69 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcquisitionDate") + .HasColumnType("datetimeoffset"); + + b.Property("AcquisitionPriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("AcquisitionType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CurrentWalletId") + .HasColumnType("int"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalQuantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("ParentLotId") + .HasColumnType("int"); + + b.Property("RemainingQuantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("SourceTradeId") + .HasColumnType("int"); + + b.Property("SourceTransactionId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TotalAcquisitionCostEur") + .HasColumnType("decimal(27, 12)"); + + b.HasKey("Id"); + + b.HasIndex("AcquisitionDate"); + + b.HasIndex("ParentLotId"); + + b.HasIndex("SourceTradeId"); + + b.HasIndex("SourceTransactionId"); + + b.HasIndex("CurrentWalletId", "Symbol"); + + b.ToTable("AssetLots"); + }); + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => { b.Property("Id") @@ -46,6 +109,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + b.Property("OppositeSymbol") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -62,6 +128,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Referenz") .HasColumnType("nvarchar(max)"); + b.Property("ResultingLotId") + .HasColumnType("int"); + b.Property("Symbol") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -78,6 +147,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasFilter("[OppositeTradeId] IS NOT NULL"); + b.HasIndex("ResultingLotId"); + b.HasIndex("WalletId"); b.ToTable("CryptoTrades"); @@ -103,6 +174,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Fee") .HasColumnType("decimal(27, 12)"); + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + b.Property("Network") .HasColumnType("nvarchar(max)"); @@ -115,6 +189,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Quantity") .HasColumnType("decimal(27, 12)"); + b.Property("ResultingLotId") + .HasColumnType("int"); + b.Property("Symbol") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -136,6 +213,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OppositeWalletId"); + b.HasIndex("ResultingLotId"); + b.HasIndex("WalletId"); b.ToTable("CryptoTransactions"); @@ -661,6 +740,68 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OkxTrades"); }); + modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("IsTaxFree") + .HasColumnType("bit"); + + b.Property("LotId") + .HasColumnType("int"); + + b.Property("MovementType") + .HasColumnType("int"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("RealizedGainEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("SalePriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("TaxFreeReason") + .HasColumnType("int"); + + b.Property("TradeId") + .HasColumnType("int"); + + b.Property("TransactionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DateTime"); + + b.HasIndex("LotId"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("TradeId"); + + b.HasIndex("TransactionId"); + + b.ToTable("LotMovements"); + }); + modelBuilder.Entity("CryptoTracker.Entities.ManualCoinPrice", b => { b.Property("Id") @@ -710,6 +851,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Wallets"); }); + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "CurrentWallet") + .WithMany() + .HasForeignKey("CurrentWalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ParentLot") + .WithMany("ChildLots") + .HasForeignKey("ParentLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTrade", "SourceTrade") + .WithMany() + .HasForeignKey("SourceTradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "SourceTransaction") + .WithMany() + .HasForeignKey("SourceTransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CurrentWallet"); + + b.Navigation("ParentLot"); + + b.Navigation("SourceTrade"); + + b.Navigation("SourceTransaction"); + }); + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => { b.HasOne("CryptoTracker.Entities.CryptoTrade", "OppositeTrade") @@ -717,6 +890,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("CryptoTracker.Entities.CryptoTrade", "OppositeTradeId") .OnDelete(DeleteBehavior.Restrict); + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") .WithMany() .HasForeignKey("WalletId") @@ -725,6 +903,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("OppositeTrade"); + b.Navigation("ResultingLot"); + b.Navigation("Wallet"); }); @@ -740,6 +920,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("OppositeWalletId") .OnDelete(DeleteBehavior.Restrict); + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") .WithMany() .HasForeignKey("WalletId") @@ -750,6 +935,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("OppositeWallet"); + b.Navigation("ResultingLot"); + b.Navigation("Wallet"); }); @@ -862,6 +1049,55 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Wallet"); }); + + modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => + { + b.HasOne("CryptoTracker.Entities.AssetLot", "Lot") + .WithMany("Movements") + .HasForeignKey("LotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTrade", "Trade") + .WithMany("LotMovements") + .HasForeignKey("TradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "Transaction") + .WithMany("LotMovements") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Lot"); + + b.Navigation("ResultingLot"); + + b.Navigation("Trade"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.Navigation("ChildLots"); + + b.Navigation("Movements"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.Navigation("LotMovements"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.Navigation("LotMovements"); + }); #pragma warning restore 612, 618 } } diff --git a/src/CryptoTracker/Services/LotService.cs b/src/CryptoTracker/Services/LotService.cs new file mode 100644 index 0000000..5e3e322 --- /dev/null +++ b/src/CryptoTracker/Services/LotService.cs @@ -0,0 +1,602 @@ +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CryptoTracker.Services; + +/// +/// Service für Asset-Lot-Management (Tranchen-Tracking für Steuerberechnung). +/// Unterstützt feingranulare Zuordnung von Lots zu Transaktionen/Trades +/// sowie optionales Auto-FIFO für schnellere Workflows. +/// +public class LotService +{ + private readonly CryptoTrackerDbContext _dbContext; + private readonly CoinRateService _coinRateService; + private readonly ILogger _logger; + + public LotService( + CryptoTrackerDbContext dbContext, + CoinRateService coinRateService, + ILogger logger) + { + _dbContext = dbContext; + _coinRateService = coinRateService; + _logger = logger; + } + + #region Lot-Abfragen + + /// + /// Holt alle verfügbaren (nicht vollständig verbrauchten) Lots für ein Wallet und Symbol. + /// Sortiert nach FIFO (älteste zuerst). + /// + public async Task> GetAvailableLotsAsync(int walletId, string symbol) + { + return await _dbContext.AssetLots + .AsNoTracking() + .Include(l => l.CurrentWallet) + .Where(l => l.CurrentWalletId == walletId + && l.Symbol == symbol.ToUpperInvariant() + && l.RemainingQuantity > 0) + .OrderBy(l => l.AcquisitionDate) + .ToListAsync(); + } + + /// + /// Holt alle verfügbaren Altbestand-Lots (vor 28.02.2021) für ein Wallet und Symbol. + /// + public async Task> GetAltbestandLotsAsync(int walletId, string symbol) + { + return await _dbContext.AssetLots + .AsNoTracking() + .Include(l => l.CurrentWallet) + .Where(l => l.CurrentWalletId == walletId + && l.Symbol == symbol.ToUpperInvariant() + && l.RemainingQuantity > 0 + && l.AcquisitionDate <= AssetLot.AltbestandStichtag) + .OrderBy(l => l.AcquisitionDate) + .ToListAsync(); + } + + /// + /// Holt alle verfügbaren Neubestand-Lots (ab 01.03.2021) für ein Wallet und Symbol. + /// + public async Task> GetNeubestandLotsAsync(int walletId, string symbol) + { + return await _dbContext.AssetLots + .AsNoTracking() + .Include(l => l.CurrentWallet) + .Where(l => l.CurrentWalletId == walletId + && l.Symbol == symbol.ToUpperInvariant() + && l.RemainingQuantity > 0 + && l.AcquisitionDate > AssetLot.AltbestandStichtag) + .OrderBy(l => l.AcquisitionDate) + .ToListAsync(); + } + + /// + /// Holt ein Lot anhand seiner ID. + /// + public async Task GetLotByIdAsync(int lotId) + { + return await _dbContext.AssetLots + .Include(l => l.CurrentWallet) + .Include(l => l.ParentLot) + .Include(l => l.SourceTrade) + .Include(l => l.SourceTransaction) + .FirstOrDefaultAsync(l => l.Id == lotId); + } + + /// + /// Berechnet den gesamten verfügbaren Bestand pro Symbol auf einem Wallet. + /// + public async Task> GetLotSummaryByWalletAsync(int walletId) + { + var lots = await _dbContext.AssetLots + .AsNoTracking() + .Where(l => l.CurrentWalletId == walletId && l.RemainingQuantity > 0) + .ToListAsync(); + + return lots + .GroupBy(l => l.Symbol) + .ToDictionary( + g => g.Key, + g => new LotSummary + { + Symbol = g.Key, + TotalQuantity = g.Sum(l => l.RemainingQuantity), + AltbestandQuantity = g.Where(l => l.IsAltbestand).Sum(l => l.RemainingQuantity), + NeubestandQuantity = g.Where(l => !l.IsAltbestand).Sum(l => l.RemainingQuantity), + TotalAcquisitionCostEur = g.Sum(l => l.RemainingAcquisitionCostEur), + LotCount = g.Count() + }); + } + + #endregion + + #region Lot-Erstellung + + /// + /// Erstellt ein Lot aus einem Fiat-Kauf (Trade vom Typ Buy mit Fiat). + /// + public async Task CreateLotFromFiatPurchaseAsync(CryptoTrade trade, decimal eurPrice) + { + if (trade.TradeType != TradeType.Buy) + throw new ArgumentException("Trade muss vom Typ Buy sein", nameof(trade)); + + var lot = new AssetLot + { + Symbol = trade.Symbol.ToUpperInvariant(), + CurrentWalletId = trade.WalletId, + RemainingQuantity = trade.QuantityAfterFee, + OriginalQuantity = trade.QuantityAfterFee, + AcquisitionDate = trade.DateTime, + AcquisitionPriceEur = eurPrice, + TotalAcquisitionCostEur = eurPrice * trade.QuantityAfterFee, + AcquisitionType = LotAcquisitionType.FiatPurchase, + SourceTradeId = trade.Id + }; + + _dbContext.AssetLots.Add(lot); + await _dbContext.SaveChangesAsync(); + + // Trade mit Lot verknüpfen + trade.ResultingLotId = lot.Id; + trade.LotAssignmentConfirmed = true; + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation( + "Lot #{LotId} erstellt: {Quantity} {Symbol} @ {Price}€ (Fiat-Kauf, Trade #{TradeId})", + lot.Id, lot.OriginalQuantity, lot.Symbol, lot.AcquisitionPriceEur, trade.Id); + + return lot; + } + + /// + /// Erstellt ein manuelles Lot (z.B. für Altbestand-Import oder externe Einzahlungen). + /// + public async Task CreateManualLotAsync(ManualLotRequest request) + { + var lot = new AssetLot + { + Symbol = request.Symbol.ToUpperInvariant(), + CurrentWalletId = request.WalletId, + RemainingQuantity = request.Quantity, + OriginalQuantity = request.Quantity, + AcquisitionDate = request.AcquisitionDate, + AcquisitionPriceEur = request.AcquisitionPriceEur, + TotalAcquisitionCostEur = request.AcquisitionPriceEur * request.Quantity, + AcquisitionType = request.AcquisitionType, + SourceTransactionId = request.SourceTransactionId, + Note = request.Note + }; + + _dbContext.AssetLots.Add(lot); + await _dbContext.SaveChangesAsync(); + + // Falls eine Transaktion verknüpft ist, diese als zugeordnet markieren + if (request.SourceTransactionId.HasValue) + { + var transaction = await _dbContext.CryptoTransactions + .FindAsync(request.SourceTransactionId.Value); + if (transaction != null) + { + transaction.ResultingLotId = lot.Id; + transaction.LotAssignmentConfirmed = true; + await _dbContext.SaveChangesAsync(); + } + } + + _logger.LogInformation( + "Manuelles Lot #{LotId} erstellt: {Quantity} {Symbol} @ {Price}€ ({Type})", + lot.Id, lot.OriginalQuantity, lot.Symbol, lot.AcquisitionPriceEur, lot.AcquisitionType); + + return lot; + } + + #endregion + + #region Lot-Verwendung (Transfer, Verkauf) + + /// + /// Führt einen Transfer von Lots zu einem anderen Wallet durch. + /// Erstellt LotMovements und neue Lots auf dem Ziel-Wallet. + /// + public async Task> TransferLotsAsync( + int sendTransactionId, + int receiveTransactionId, + IList allocations) + { + var sendTransaction = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == sendTransactionId) + ?? throw new ArgumentException($"Send-Transaktion {sendTransactionId} nicht gefunden"); + + var receiveTransaction = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == receiveTransactionId) + ?? throw new ArgumentException($"Receive-Transaktion {receiveTransactionId} nicht gefunden"); + + var resultingLots = new List(); + + foreach (var allocation in allocations) + { + var lot = await _dbContext.AssetLots.FindAsync(allocation.LotId) + ?? throw new ArgumentException($"Lot {allocation.LotId} nicht gefunden"); + + if (lot.RemainingQuantity < allocation.Quantity) + throw new InvalidOperationException( + $"Lot #{lot.Id} hat nur {lot.RemainingQuantity} verfügbar, aber {allocation.Quantity} angefordert"); + + // Lot reduzieren + lot.RemainingQuantity -= allocation.Quantity; + + // Neues Lot auf Ziel-Wallet erstellen + var newLot = new AssetLot + { + Symbol = lot.Symbol, + CurrentWalletId = receiveTransaction.WalletId, + RemainingQuantity = allocation.Quantity, + OriginalQuantity = allocation.Quantity, + AcquisitionDate = lot.AcquisitionDate, // Kaufdatum beibehalten! + AcquisitionPriceEur = lot.AcquisitionPriceEur, + TotalAcquisitionCostEur = lot.AcquisitionPriceEur * allocation.Quantity, + AcquisitionType = LotAcquisitionType.InternalTransfer, + SourceTransactionId = receiveTransactionId, + ParentLotId = lot.Id, + Note = $"Transfer von {sendTransaction.Wallet.Name}" + }; + + _dbContext.AssetLots.Add(newLot); + await _dbContext.SaveChangesAsync(); + + // Movement erstellen + var movement = new LotMovement + { + LotId = lot.Id, + Quantity = allocation.Quantity, + DateTime = sendTransaction.DateTime, + MovementType = LotMovementType.Transfer, + TransactionId = sendTransactionId, + IsTaxFree = true, + TaxFreeReason = TaxFreeReason.InternalTransfer, + ResultingLotId = newLot.Id + }; + + _dbContext.LotMovements.Add(movement); + resultingLots.Add(newLot); + } + + // Transaktionen als zugeordnet markieren + sendTransaction.LotAssignmentConfirmed = true; + receiveTransaction.LotAssignmentConfirmed = true; + receiveTransaction.ResultingLotId = resultingLots.FirstOrDefault()?.Id; + + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation( + "Transfer abgeschlossen: {Count} Lots von Wallet #{FromWallet} zu #{ToWallet}", + allocations.Count, sendTransaction.WalletId, receiveTransaction.WalletId); + + return resultingLots; + } + + /// + /// Führt einen Verkauf gegen Fiat durch und berechnet Gewinn/Verlust. + /// + public async Task SellLotsAsync( + int tradeId, + IList allocations, + decimal salePriceEurPerUnit) + { + var trade = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == tradeId) + ?? throw new ArgumentException($"Trade {tradeId} nicht gefunden"); + + if (trade.TradeType != TradeType.Sell) + throw new ArgumentException("Trade muss vom Typ Sell sein"); + + var result = new SaleResult(); + + foreach (var allocation in allocations) + { + var lot = await _dbContext.AssetLots.FindAsync(allocation.LotId) + ?? throw new ArgumentException($"Lot {allocation.LotId} nicht gefunden"); + + if (lot.RemainingQuantity < allocation.Quantity) + throw new InvalidOperationException( + $"Lot #{lot.Id} hat nur {lot.RemainingQuantity} verfügbar, aber {allocation.Quantity} angefordert"); + + // Gewinn/Verlust berechnen + var acquisitionCost = lot.AcquisitionPriceEur * allocation.Quantity; + var saleProceeds = salePriceEurPerUnit * allocation.Quantity; + var realizedGain = saleProceeds - acquisitionCost; + var isTaxFree = lot.IsAltbestand; + + // Lot reduzieren + lot.RemainingQuantity -= allocation.Quantity; + + // Movement erstellen + var movement = new LotMovement + { + LotId = lot.Id, + Quantity = allocation.Quantity, + DateTime = trade.DateTime, + MovementType = LotMovementType.FiatSale, + TradeId = tradeId, + SalePriceEur = salePriceEurPerUnit, + RealizedGainEur = realizedGain, + IsTaxFree = isTaxFree, + TaxFreeReason = isTaxFree ? TaxFreeReason.Altbestand : null + }; + + _dbContext.LotMovements.Add(movement); + + // Ergebnis aggregieren + result.TotalQuantity += allocation.Quantity; + result.TotalAcquisitionCost += acquisitionCost; + result.TotalSaleProceeds += saleProceeds; + result.TotalRealizedGain += realizedGain; + + if (isTaxFree) + { + result.TaxFreeGain += realizedGain; + result.TaxFreeQuantity += allocation.Quantity; + } + else + { + result.TaxableGain += realizedGain; + result.TaxableQuantity += allocation.Quantity; + } + } + + // KESt berechnen (27,5% auf steuerpflichtigen Gewinn) + if (result.TaxableGain > 0) + { + result.EstimatedKESt = result.TaxableGain * 0.275m; + } + + // Trade als zugeordnet markieren + trade.LotAssignmentConfirmed = true; + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation( + "Verkauf abgeschlossen: {Quantity} {Symbol}, Gewinn: {Gain}€ (steuerfrei: {TaxFree}€, steuerpflichtig: {Taxable}€)", + result.TotalQuantity, trade.Symbol, result.TotalRealizedGain, result.TaxFreeGain, result.TaxableGain); + + return result; + } + + #endregion + + #region Auto-FIFO + + /// + /// Schlägt eine FIFO-Zuordnung für eine bestimmte Menge vor. + /// Priorisiert Altbestand (steuerfrei) wenn gewünscht. + /// + public async Task> SuggestFifoAllocationAsync( + int walletId, + string symbol, + decimal requiredQuantity, + bool prioritizeAltbestand = false) + { + var lots = await GetAvailableLotsAsync(walletId, symbol); + + if (prioritizeAltbestand) + { + // Altbestand zuerst, dann Neubestand + lots = lots + .OrderByDescending(l => l.IsAltbestand) + .ThenBy(l => l.AcquisitionDate) + .ToList(); + } + + var allocations = new List(); + var remaining = requiredQuantity; + + foreach (var lot in lots) + { + if (remaining <= 0) break; + + var toAllocate = Math.Min(lot.RemainingQuantity, remaining); + allocations.Add(new LotAllocation + { + LotId = lot.Id, + Quantity = toAllocate, + Lot = lot + }); + remaining -= toAllocate; + } + + if (remaining > 0) + { + _logger.LogWarning( + "FIFO-Vorschlag unvollständig: {Missing} {Symbol} fehlen auf Wallet #{WalletId}", + remaining, symbol, walletId); + } + + return allocations; + } + + #endregion + + #region Lot-Generierung aus bestehenden Daten + + /// + /// Fiat-Symbole für EF Core Query (muss als statisches Array definiert sein). + /// Enthält auch Lowercase-Varianten für Case-Insensitive-Vergleich. + /// + private static readonly string[] FiatSymbolsForQuery = { "EUR", "USD", "CHF", "GBP", "ZEUR", "ZUSD", "eur", "usd", "chf", "gbp", "zeur", "zusd" }; + + /// + /// Generiert Lots aus bestehenden Buy-Trades die noch kein Lot haben. + /// + public async Task GenerateLotsFromExistingTradesAsync() + { + // Hinweis: FiatSymbolsForQuery muss inline verwendet werden, da EF Core keine Methodenaufrufe übersetzen kann + // Contains-Vergleich ist case-sensitive, daher enthält das Array beide Varianten + var tradesWithoutLots = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .Where(t => t.TradeType == TradeType.Buy + && t.ResultingLotId == null + && FiatSymbolsForQuery.Contains(t.OppositeSymbol)) + .OrderBy(t => t.DateTime) + .ToListAsync(); + + var count = 0; + foreach (var trade in tradesWithoutLots) + { + try + { + // EUR-Preis ermitteln + decimal eurPrice; + if (trade.OppositeSymbol.Equals("EUR", StringComparison.OrdinalIgnoreCase)) + { + eurPrice = trade.Price; + } + else + { + // Für andere Fiat-Währungen: Kurs zum Zeitpunkt holen + var (rate, _) = await _coinRateService.GetPreviousCloseRateWithSourceAsync( + trade.OppositeSymbol, + trade.DateTime.UtcDateTime); + eurPrice = rate.HasValue ? trade.Price * rate.Value : trade.Price; + } + + await CreateLotFromFiatPurchaseAsync(trade, eurPrice); + count++; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler beim Erstellen von Lot für Trade #{TradeId}", trade.Id); + } + } + + _logger.LogInformation("{Count} Lots aus bestehenden Trades generiert", count); + return count; + } + + private static bool IsFiatSymbol(string symbol) + { + return FiatSymbolsForQuery.Contains(symbol.ToUpperInvariant()); + } + + #endregion + + #region Transaktionen ohne Lot-Zuordnung + + /// + /// Findet alle Transaktionen die eine Lot-Zuordnung benötigen. + /// + public async Task> GetTransactionsRequiringLotAssignmentAsync() + { + return await _dbContext.CryptoTransactions + .AsNoTracking() + .Include(t => t.Wallet) + .Include(t => t.OppositeWallet) + .Where(t => t.TransactionType == TransactionType.Receive + && t.OppositeTransactionId == null + && !t.LotAssignmentConfirmed) + .OrderBy(t => t.DateTime) + .ToListAsync(); + } + + /// + /// Findet alle Trades die eine Lot-Zuordnung benötigen (Verkäufe ohne zugeordnete Lots). + /// + public async Task> GetTradesRequiringLotAssignmentAsync() + { + return await _dbContext.CryptoTrades + .AsNoTracking() + .Include(t => t.Wallet) + .Where(t => t.TradeType == TradeType.Sell + && !t.LotAssignmentConfirmed) + .OrderBy(t => t.DateTime) + .ToListAsync(); + } + + #endregion +} + +#region DTOs und Request-Klassen + +/// +/// Zusammenfassung der Lots pro Symbol. +/// +public class LotSummary +{ + public string Symbol { get; set; } = string.Empty; + public decimal TotalQuantity { get; set; } + public decimal AltbestandQuantity { get; set; } + public decimal NeubestandQuantity { get; set; } + public decimal TotalAcquisitionCostEur { get; set; } + public int LotCount { get; set; } + + /// + /// Durchschnittlicher Anschaffungspreis pro Einheit. + /// + public decimal AverageAcquisitionPrice => + TotalQuantity > 0 ? TotalAcquisitionCostEur / TotalQuantity : 0; +} + +/// +/// Request für manuelle Lot-Erstellung. +/// +public class ManualLotRequest +{ + public string Symbol { get; set; } = string.Empty; + public int WalletId { get; set; } + public decimal Quantity { get; set; } + public DateTimeOffset AcquisitionDate { get; set; } + public decimal AcquisitionPriceEur { get; set; } + public LotAcquisitionType AcquisitionType { get; set; } + public int? SourceTransactionId { get; set; } + public string? Note { get; set; } +} + +/// +/// Zuordnung einer bestimmten Menge eines Lots. +/// +public class LotAllocation +{ + public int LotId { get; set; } + public decimal Quantity { get; set; } + + /// + /// Optional: Lot-Objekt für Anzeige in UI. + /// + public AssetLot? Lot { get; set; } +} + +/// +/// Ergebnis eines Verkaufs. +/// +public class SaleResult +{ + public decimal TotalQuantity { get; set; } + public decimal TotalAcquisitionCost { get; set; } + public decimal TotalSaleProceeds { get; set; } + public decimal TotalRealizedGain { get; set; } + + /// + /// Steuerfreier Gewinn (Altbestand). + /// + public decimal TaxFreeGain { get; set; } + public decimal TaxFreeQuantity { get; set; } + + /// + /// Steuerpflichtiger Gewinn (Neubestand). + /// + public decimal TaxableGain { get; set; } + public decimal TaxableQuantity { get; set; } + + /// + /// Geschätzte KESt (27,5% auf steuerpflichtigen Gewinn). + /// + public decimal EstimatedKESt { get; set; } +} + +#endregion diff --git a/src/CryptoTracker/Startup.cs b/src/CryptoTracker/Startup.cs index 6e13b58..3b95ca1 100644 --- a/src/CryptoTracker/Startup.cs +++ b/src/CryptoTracker/Startup.cs @@ -66,6 +66,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -76,6 +77,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/src/CryptoTracker/wwwroot/app.css b/src/CryptoTracker/wwwroot/app.css index 7fa587d..faf948c 100644 --- a/src/CryptoTracker/wwwroot/app.css +++ b/src/CryptoTracker/wwwroot/app.css @@ -791,3 +791,415 @@ h1, h2, h3, .app-title { gap: 0.35rem; align-items: center; } + +/* ============================================ + LOT MANAGEMENT STYLES + ============================================ */ + +.lots-overview { + margin-top: 1.5rem; +} + +.lot-summary-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.lot-summary-card { + background: var(--surface-100); + border: 1px solid var(--surface-300); + border-radius: var(--radius-md); + padding: 1rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.lot-summary-card:hover { + border-color: var(--ink-700); + box-shadow: var(--shadow-soft); +} + +.lot-summary-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--surface-300); +} + +.lot-count { + font-size: 0.75rem; + color: var(--ink-500); + background: var(--surface-200); + padding: 0.2rem 0.5rem; + border-radius: 999px; +} + +.lot-summary-card-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.lot-summary-stat { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.lot-summary-stat label { + font-size: 0.7rem; + color: var(--ink-500); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.lot-summary-stat span { + font-weight: 600; + color: var(--ink-900); +} + +.lot-summary-stat.altbestand span { + color: var(--mint-500); +} + +.lot-summary-stat.neubestand span { + color: var(--ink-700); +} + +.lots-detail { + margin-top: 2rem; +} + +.lots-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.pending-assignments { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--surface-300); +} + +/* Lot Badge */ +.lot-badge { + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-weight: 600; +} + +.altbestand-badge { + background: rgba(31, 143, 106, 0.15); + color: var(--mint-500); +} + +.neubestand-badge { + background: rgba(47, 52, 58, 0.12); + color: var(--ink-700); +} + +/* Lot Selector Component */ +.lot-selector { + background: var(--surface-100); + border: 1px solid var(--surface-300); + border-radius: var(--radius-md); + padding: 1.25rem; +} + +.lot-selector-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--surface-300); +} + +.lot-selector-header h4 { + margin: 0; + font-size: 1rem; +} + +.lot-selector-loading, +.lot-selector-empty { + padding: 2rem; + text-align: center; + color: var(--ink-500); +} + +.lot-selector-actions { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.lot-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-height: 400px; + overflow-y: auto; +} + +.lot-item { + background: var(--surface-200); + border: 2px solid transparent; + border-radius: var(--radius-sm); + padding: 0.75rem; + transition: all 0.2s ease; +} + +.lot-item.lot-selected { + border-color: var(--ink-700); + background: var(--surface-100); +} + +.lot-item.lot-altbestand { + border-left: 4px solid var(--mint-500); +} + +.lot-item.lot-neubestand { + border-left: 4px solid var(--ink-500); +} + +.lot-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.lot-id { + font-weight: 600; + font-size: 0.85rem; + color: var(--ink-900); +} + +.lot-details { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + margin-bottom: 0.75rem; + font-size: 0.8rem; +} + +.lot-detail { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.lot-detail label { + font-size: 0.65rem; + color: var(--ink-500); + text-transform: uppercase; +} + +.lot-detail span { + color: var(--ink-900); +} + +.lot-allocation { + display: flex; + align-items: center; + gap: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--surface-300); +} + +.lot-allocation label { + font-size: 0.8rem; + color: var(--ink-500); +} + +.lot-input { + width: 120px; + padding: 0.3rem 0.5rem; + border: 1px solid var(--surface-300); + border-radius: var(--radius-sm); + font-size: 0.85rem; +} + +.lot-input:focus { + outline: none; + border-color: var(--ink-700); +} + +.lot-unit { + font-size: 0.8rem; + color: var(--ink-500); +} + +.lot-summary { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--surface-300); +} + +.lot-summary-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.25rem 0; + font-size: 0.9rem; +} + +.lot-summary-section { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px dashed var(--surface-300); +} + +.lot-summary-title { + font-weight: 600; + font-size: 0.8rem; + margin-bottom: 0.5rem; + color: var(--ink-700); +} + +.lot-summary-highlight { + font-weight: 600; + background: var(--surface-200); + padding: 0.5rem; + border-radius: var(--radius-sm); + margin-top: 0.5rem; +} + +.lot-summary-warning { + margin-top: 0.75rem; + padding: 0.5rem; + background: rgba(194, 70, 84, 0.1); + color: var(--rose-500); + border-radius: var(--radius-sm); + font-size: 0.85rem; + text-align: center; +} + +.text-success { + color: var(--mint-500) !important; +} + +.text-warning { + color: #a26d00 !important; +} + +.text-danger { + color: var(--rose-500) !important; +} + +.text-muted { + color: var(--ink-500); +} + +.text-info { + color: #0d6efd; +} + +/* Toast notification */ +.toast { + position: fixed; + bottom: 20px; + right: 20px; + padding: 1rem 1.5rem; + border-radius: var(--radius-md); + box-shadow: var(--shadow-soft); + z-index: 9999; + cursor: pointer; + animation: slideIn 0.3s ease; +} + +.toast-success { + background: var(--mint-500); + color: white; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Modal styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-card { + background: var(--surface-100); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--surface-300); +} + +.modal-header h3 { + margin: 0; +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--surface-300); +} + +.page-actions { + display: flex; + gap: 0.5rem; +} + +.loading-spinner-small { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 0.8s linear infinite; + margin-right: 0.5rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + From d7fccf72674ac97788c5f4afbbf277a4537330e1 Mon Sep 17 00:00:00 2001 From: Thomas Humer Date: Sat, 31 Jan 2026 14:00:05 +0100 Subject: [PATCH 2/7] feat: Add Lot Flow Tracking and Validation Logic - Introduced migration to add new columns for tracking lot flow in CryptoTrades and AssetLots. - Implemented LotFlowValidator service to validate the flow of asset lots for tax purposes. - Added logic to handle various acquisition types and their validation rules. - Created result classes for validation outcomes and flow steps. - Enhanced database context interactions to support new validation requirements. --- docs/LotFlowTracking.md | 364 ++++++ src/CryptoTracker.Client/Shared/LotDTOs.cs | 46 +- .../Shared/LotSelector.razor | 59 +- .../Controllers/LotsController.cs | 78 +- .../DbContext/CryptoTrackerDbContext.cs | 10 + src/CryptoTracker/Entities/AssetLot.cs | 25 + src/CryptoTracker/Entities/CryptoTrade.cs | 7 + ...60131122534_AddLotFlowTracking.Designer.cs | 1139 +++++++++++++++++ .../20260131122534_AddLotFlowTracking.cs | 101 ++ .../CryptoTrackerDbContextModelSnapshot.cs | 32 + .../Services/LotFlowValidator.cs | 412 ++++++ src/CryptoTracker/Services/LotService.cs | 201 ++- src/CryptoTracker/Startup.cs | 1 + src/CryptoTracker/wwwroot/app.css | 149 +++ 14 files changed, 2600 insertions(+), 24 deletions(-) create mode 100644 docs/LotFlowTracking.md create mode 100644 src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.Designer.cs create mode 100644 src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.cs create mode 100644 src/CryptoTracker/Services/LotFlowValidator.cs diff --git a/docs/LotFlowTracking.md b/docs/LotFlowTracking.md new file mode 100644 index 0000000..e04bc4f --- /dev/null +++ b/docs/LotFlowTracking.md @@ -0,0 +1,364 @@ +# Lot Flow Tracking - Dokumentation + +## Übersicht + +Das Lot Flow Tracking System ermöglicht die vollständige Nachverfolgung von Krypto-Assets von ihrer Anschaffung bis zum Verkauf. Dies ist für die österreichische Steuerberechnung (KESt 27.5%) essentiell, da nur Lots mit **vollständig nachvollziehbarem Flow** für steuerliche Zwecke verwendet werden dürfen. + +### Warum Flow-Tracking? + +Ein Lot kann nur für die Steuerberechnung verwendet werden, wenn seine gesamte Kette nachvollziehbar ist: + +- **Beispiel 1:** Du kaufst 1 BTC mit EUR → Swap zu ETH → Swap zurück zu 0.8 BTC + Das ursprüngliche Lot zeigt noch 1 BTC, aber du hast nur 0.8 BTC. Ohne Flow-Tracking wäre die Steuerbasis falsch. + +- **Beispiel 2:** Externe Einzahlung von 2 ETH ohne Herkunftsnachweis + Dieses Lot kann nicht für steuerliche Zwecke verwendet werden, da die Anschaffungskosten unbekannt sind. + +--- + +## Kernkonzepte + +### 1. AssetLot (Steuerliches Losmodell) + +Ein `AssetLot` repräsentiert eine steuerliche Einheit eines Krypto-Assets mit: + +| Feld | Beschreibung | +|------|--------------| +| `AcquisitionDate` | Anschaffungsdatum - wichtig für Altbestand (vor 01.03.2021) | +| `AcquisitionCostEur` | Anschaffungskosten in EUR - Basis für Gewinn/Verlust | +| `OriginalQuantity` | Ursprüngliche Menge | +| `RemainingQuantity` | Verbleibende Menge (nach Teil-Verkäufen) | +| `AcquisitionType` | Herkunftstyp (FiatPurchase, CryptoSwap, etc.) | + +### 2. Flow-Status Felder + +| Feld | Beschreibung | +|------|--------------| +| `IsFlowComplete` | Ist die Kette bis zum Ursprung vollständig nachvollziehbar? | +| `FlowIncompleteReason` | Grund für unvollständigen Flow (falls zutreffend) | +| `TransformedToLotId` | Bei Swap: Zeigt auf das neue Lot (Ziel) | +| `TransformedFromLots` | Bei Swap: Collection der Source-Lots (Quellen) | +| `ParentLotId` | Bei Transfer: Zeigt auf das ursprüngliche Lot | + +--- + +## Herkunftstypen (AcquisitionType) + +| Typ | Flow-Status | Beschreibung | +|-----|-------------|--------------| +| `FiatPurchase` | ✅ Immer vollständig | Kauf mit EUR/USD - Ursprung der Kette | +| `Manual` | ✅ Immer vollständig | Manuell eingetragen - User übernimmt Verantwortung | +| `Mining` | ✅ Immer vollständig | Mining-Reward - Anschaffung zu Zeitwert | +| `Staking` | ✅ Immer vollständig | Staking-Reward - Anschaffung zu Zeitwert | +| `Lending` | ✅ Immer vollständig | Lending-Zinsen - Anschaffung zu Zeitwert | +| `Airdrop` | ✅ Immer vollständig | Airdrop - Anschaffung zu Zeitwert | +| `Hardfork` | ✅ Immer vollständig | Hardfork-Coins - Anschaffung zu Zeitwert | +| `Gift` | ✅ Immer vollständig | Schenkung - Anschaffung zu Zeitwert | +| `InternalTransfer` | 🔗 Abhängig | Vollständig wenn ParentLot vollständig UND OppositeTransaction existiert | +| `CryptoSwap` | 🔗 Abhängig | Vollständig wenn OppositeTrade existiert UND alle Source-Lots vollständig | +| `ExternalDeposit` | ❌ Immer unvollständig | Externe Einzahlung ohne nachweisbare Herkunft | + +--- + +## Entity-Beziehungen + +### CryptoTrade (Käufe/Verkäufe/Swaps) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CryptoTrade │ +├─────────────────────────────────────────────────────────────┤ +│ - OppositeTradeId → CryptoTrade (Swap-Partner) │ +│ - SourceLotId → AssetLot (Bei Sell: verwendetes Lot) │ +│ - ResultingLotId → AssetLot (Bei Buy: erstelltes Lot)│ +│ - LotMovements → [LotMovement] (Lot-Allokationen) │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Verwendung:** +- **Fiat-Kauf:** `ResultingLotId` zeigt auf das neue Lot +- **Fiat-Verkauf:** `LotMovements` dokumentiert welche Lots verwendet wurden +- **Crypto-Swap (Sell-Seite):** `SourceLotId` zeigt auf das verwendete Lot +- **Crypto-Swap (Buy-Seite):** `ResultingLotId` zeigt auf das neue Lot + +### CryptoTransaction (Wallet-Transfers) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CryptoTransaction │ +├─────────────────────────────────────────────────────────────┤ +│ - OppositeTransactionId → CryptoTransaction (Transfer-Partner) │ +│ - ResultingLotId → AssetLot (Bei Receive: erstelltes Lot) │ +│ - LotMovements → [LotMovement] (Lot-Allokationen) │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Verwendung:** +- **Send:** `LotMovements` dokumentiert welche Lots gesendet wurden +- **Receive:** `ResultingLotId` zeigt auf das neue Lot +- **Verknüpfung:** `OppositeTransactionId` verbindet Send ↔ Receive + +### AssetLot (Lot-Verknüpfungen) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AssetLot │ +├─────────────────────────────────────────────────────────────┤ +│ - ParentLotId → AssetLot (Transfer: Ursprungslot) │ +│ - TransformedToLotId → AssetLot (Swap: Ziellot) │ +│ - TransformedFromLots → [AssetLot] (Swap: Quelllots) │ +│ - SourceTradeId → CryptoTrade (Herkunfts-Trade) │ +│ - SourceTransactionId → CryptoTransaction (Herkunfts-Tx) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Unterschied: Trade vs Transaction Verknüpfung + +| Szenario | Entity | Verknüpfung | Lot-Verknüpfung | +|----------|--------|-------------|-----------------| +| **Wallet Transfer** | `CryptoTransaction` | `OppositeTransactionId` | `ParentLotId` (neues Lot erbt vom alten) | +| **Crypto Swap** | `CryptoTrade` | `OppositeTradeId` | `SourceLotId` + `TransformedFromLots` | + +Bei **Transactions** nutzen wir `OppositeTransaction` automatisch für die Flow-Validierung und `ParentLotId` für die Lot-Kette. + +Bei **Trades** brauchen wir `SourceLotId` zusätzlich, weil Swaps komplexer sind (mehrere Source-Lots können in ein neues Lot transformiert werden). + +--- + +## Ablauf-Diagramme + +### 1. Fiat-Kauf (Ursprung) + +``` +┌─────────────────┐ ┌─────────────────┐ +│ Trade (Buy) │ │ Lot #1 │ +│ 1.0 BTC │───────────▶│ 1.0 BTC │ +│ Price: €50.000 │ Resulting │ AcquisitionCost │ +│ OppositeSymbol: │ LotId │ = €50.000 │ +│ EUR │ │ Flow: ✅ │ +└─────────────────┘ └─────────────────┘ + +Lot #1: + - AcquisitionType = FiatPurchase + - AcquisitionDate = Trade.DateTime + - IsFlowComplete = true (Ursprung - immer vollständig) +``` + +### 2. Wallet Transfer (Intern) + +``` +Wallet A Wallet B +┌──────────┐ ┌──────────┐ +│ Lot #1 │ │ Lot #2 │ +│ 1.0 BTC │ │ 1.0 BTC │ +│ Flow: ✅ │ │ Flow: ✅ │ +└────┬─────┘ └────▲─────┘ + │ │ + │ Send-Transaction │ Receive-Transaction + │ (LotMovement: Outflow) │ (ResultingLotId) + │ │ + └────────────────────────────────────┘ + OppositeTransactionId +``` + +**Lot #2 Eigenschaften:** +- `ParentLotId` = #1 +- `AcquisitionType` = InternalTransfer +- `AcquisitionDate` = von Lot #1 übernommen +- `AcquisitionCostEur` = von Lot #1 übernommen +- `IsFlowComplete` = true (wenn Lot #1 vollständig UND OppositeTransaction existiert) + +### 3. Crypto-to-Crypto Swap + +``` + OppositeTrade + ┌────────────────────────────────┐ + │ │ + ▼ │ +┌─────────────────┐ ┌─────────────────┐ +│ Trade #1 (Sell) │ │ Trade #2 (Buy) │ +│ 1.0 BTC │ │ 15.0 ETH │ +│ SourceLotId=#1 │ │ ResultingLotId=#2│ +└────────┬────────┘ └────────▲────────┘ + │ │ + │ │ +┌────────▼────────┐ ┌────────┴────────┐ +│ Lot #1 (Source) │ │ Lot #2 (Result) │ +│ BTC │───────────▶│ ETH │ +│ RemainingQty: 0 │ Transformed│ TransformedFrom │ +│ │ ToLotId │ Lots = [#1] │ +└─────────────────┘ └─────────────────┘ +``` + +**Lot #2 Eigenschaften:** +- `AcquisitionType` = CryptoSwap +- `AcquisitionDate` = frühestes Datum aus Source-Lots (wichtig für Altbestand!) +- `AcquisitionCostEur` = gewichteter Durchschnitt aus Source-Lots +- `TransformedFromLots` = [Lot #1] +- `IsFlowComplete` = true (wenn alle Source-Lots vollständig UND OppositeTrade existiert) + +**Lot #1 wird aktualisiert:** +- `TransformedToLotId` = #2 +- `RemainingQuantity` = 0 + +### 4. Verkauf gegen Fiat + +``` +┌─────────────────┐ ┌─────────────────┐ +│ Lot #1 │ │ Trade (Sell) │ +│ 1.0 BTC │───────────▶│ 1.0 BTC │ +│ AcquisitionCost │ LotMovement│ Price: €60.000 │ +│ = €50.000 │ (Outflow) │ OppositeSymbol: │ +│ Flow: ✅ │ │ EUR │ +└─────────────────┘ └─────────────────┘ +``` + +**Steuerberechnung (nur wenn `IsFlowComplete = true`):** +- Verkaufserlös: €60.000 +- Anschaffungskosten: €50.000 +- Gewinn: €10.000 +- KESt (27.5%): €2.750 + +--- + +## Flow-Validierung (LotFlowValidator) + +### Validierungslogik + +``` +ValidateLotFlowAsync(lotId) +│ +├── FiatPurchase / Manual / Mining / Staking / etc. +│ └── ✅ IsComplete = true (Ursprung - keine weitere Prüfung nötig) +│ +├── InternalTransfer +│ ├── ParentLotId vorhanden? +│ │ └── Nein → ❌ "Interner Transfer ohne Parent-Lot" +│ ├── SourceTransaction.OppositeTransactionId vorhanden? +│ │ └── Nein → ❌ "Transfer-Transaktion hat keine Gegentransaktion" +│ └── Rekursiv: ParentLot validieren +│ └── ParentLot.IsComplete? → ✅ / ❌ +│ +├── CryptoSwap +│ ├── SourceTradeId vorhanden? +│ │ └── Nein → ❌ "Crypto-Swap hat keinen Source-Trade" +│ ├── SourceTrade.OppositeTradeId vorhanden? +│ │ └── Nein → ❌ "Swap-Trade hat keinen Gegentrade" +│ ├── TransformedFromLots oder ParentLot vorhanden? +│ │ └── Nein → ❌ "Crypto-Swap hat keine Source-Lots" +│ └── Rekursiv: Alle Source-Lots validieren +│ └── Alle Source-Lots vollständig? → ✅ / ❌ +│ +└── ExternalDeposit + └── ❌ "Externe Einzahlung ohne vollständige Herkunftsdokumentation" +``` + +### Zirkuläre Referenzen + +Der Validator erkennt und verhindert zirkuläre Referenzen (z.B. Lot A → Lot B → Lot A). + +--- + +## Services + +### LotService + +| Methode | Beschreibung | +|---------|--------------| +| `GetAvailableLotsAsync(symbol, walletId, onlyCompleteFlow)` | Verfügbare Lots für ein Asset abrufen | +| `GetAllAvailableLotsWithFlowStatusAsync(symbol)` | Alle Lots mit Flow-Status | +| `AllocateLotForSaleAsync(lotId, quantity, tradeId)` | Lot für Verkauf allokieren | +| `TransferLotAsync(lotId, quantity, fromTx, toTx)` | Lot zwischen Wallets transferieren | +| `TransformLotsViaSwapAsync(request)` | Lots über Crypto-Swap transformieren | + +### LotFlowValidator + +| Methode | Beschreibung | +|---------|--------------| +| `ValidateLotFlowAsync(lotId)` | Einzelnes Lot validieren | +| `ValidateAndUpdateAllLotsAsync()` | Alle Lots validieren und `IsFlowComplete` aktualisieren | +| `GetLotsWithIncompleteFlowAsync(walletId?)` | Lots mit unvollständigem Flow abrufen | + +--- + +## API-Endpunkte + +| Methode | Endpunkt | Beschreibung | +|---------|----------|--------------| +| GET | `/api/lots` | Alle Lots abrufen (optional mit Filter) | +| GET | `/api/lots/{id}` | Einzelnes Lot abrufen | +| GET | `/api/lots/available/{symbol}` | Verfügbare Lots für ein Symbol | +| GET | `/api/lots/flow/{lotId}` | Flow-Status eines Lots validieren | +| POST | `/api/lots/flow/revalidate` | Alle Lots neu validieren | +| GET | `/api/lots/incomplete-flow` | Lots mit unvollständigem Flow | +| POST | `/api/lots/transform-swap` | Lots über Swap transformieren | +| POST | `/api/lots/allocate` | Lot für Verkauf allokieren | + +--- + +## UI-Komponenten + +### LotSelector.razor + +Ermöglicht die Auswahl von Lots für Verkäufe/Swaps. + +**Features:** +- Zeigt Flow-Status-Badge für jedes Lot (✅ Vollständig / ⚠️ Unvollständig) +- Deaktiviert Lots mit unvollständigem Flow standardmäßig +- `AllowIncompleteFlow` Parameter für manuelle Override (mit Warnung) +- FIFO-Vorschlag berücksichtigt nur vollständige Lots +- Sortierung: Vollständige Lots zuerst, dann nach Datum + +**CSS-Klassen:** +- `.lot-flow-incomplete` - Grau hinterlegt, gestreifter Hintergrund +- `.lot-badge-flow-complete` - Grünes Badge +- `.lot-badge-flow-incomplete` - Gelbes Badge +- `.flow-warning-message` - Warnhinweis bei unvollständigem Flow + +--- + +## Datenbank-Migrationen + +| Migration | Beschreibung | +|-----------|--------------| +| `20260130220034_AddAssetLotTracking` | Basis-Lot-System (AssetLot, LotMovement) | +| `20260131122534_AddLotFlowTracking` | Flow-Tracking Erweiterung (IsFlowComplete, TransformedToLotId, SourceLotId) | + +--- + +## Nächste Schritte (Automatik für Verknüpfungen) + +Die folgenden Features sollten als nächstes implementiert werden: + +### 1. Automatische Lot-Erstellung bei Fiat-Kauf +Wenn ein Trade mit `OppositeSymbol = EUR/USD` erstellt wird, automatisch ein Lot erstellen. + +### 2. Automatische Lot-Verknüpfung bei gepaartem Transfer +Wenn eine `CryptoTransaction` mit `OppositeTransactionId` erstellt wird: +- Send-Seite: `LotMovement` erstellen +- Receive-Seite: Neues Lot mit `ParentLotId` erstellen + +### 3. Automatische Swap-Transformation bei gepaartem Trade +Wenn zwei `CryptoTrade`s über `OppositeTradeId` verknüpft werden: +- Sell-Seite: `SourceLotId` setzen +- Buy-Seite: Neues Lot mit `TransformedFromLots` erstellen + +### 4. Batch-Revalidierung nach Import +Nach jedem Daten-Import automatisch `ValidateAndUpdateAllLotsAsync()` aufrufen. + +### 5. UI für manuelle Lot-Zuweisung +Für externe Einzahlungen ohne automatische Verknüpfung eine UI bereitstellen, um Lots manuell als "Manual" zu markieren. + +--- + +## Glossar + +| Begriff | Beschreibung | +|---------|--------------| +| **Lot** | Steuerliche Einheit eines Krypto-Assets mit Anschaffungsdatum und -kosten | +| **Flow** | Die Kette von Transaktionen/Trades von der Anschaffung bis zum aktuellen Zustand | +| **FIFO** | First-In-First-Out - Steuerliche Veräußerungsreihenfolge | +| **Altbestand** | Assets vor 01.03.2021 angeschafft (steuerfrei in Österreich) | +| **KESt** | Kapitalertragsteuer (27.5% in Österreich) | +| **Swap** | Tausch Crypto-to-Crypto (z.B. BTC → ETH) | +| **Transfer** | Bewegung zwischen eigenen Wallets (kein steuerliches Ereignis) | diff --git a/src/CryptoTracker.Client/Shared/LotDTOs.cs b/src/CryptoTracker.Client/Shared/LotDTOs.cs index b258b36..f9c7da7 100644 --- a/src/CryptoTracker.Client/Shared/LotDTOs.cs +++ b/src/CryptoTracker.Client/Shared/LotDTOs.cs @@ -19,7 +19,9 @@ public record LotDTO( string? Note, int? ParentLotId, int? SourceTradeId, - int? SourceTransactionId); + int? SourceTransactionId, + bool IsFlowComplete = true, + string? FlowIncompleteReason = null); /// /// DTO für Lot-Zusammenfassung pro Symbol. @@ -118,3 +120,45 @@ public record GenerateLotsRequest( public record GenerateLotsResultDTO( int LotsCreated, int Errors); + +/// +/// DTO für Flow-Validierungsergebnis. +/// +public record LotFlowValidationDTO( + int LotId, + string Symbol, + decimal Quantity, + DateTimeOffset AcquisitionDate, + bool IsComplete, + string? IncompleteReason, + IList FlowChain, + decimal EffectiveQuantity); + +/// +/// DTO für einen Schritt in der Flow-Kette. +/// +public record LotFlowStepDTO( + int LotId, + string Symbol, + decimal Quantity, + string Type, + int? TradeId, + int? TransactionId, + DateTimeOffset DateTime); + +/// +/// Request für Swap-Transformation. +/// +public record TransformLotsViaSwapRequest( + int SellTradeId, + int BuyTradeId, + IList SourceAllocations, + decimal ResultingQuantity); + +/// +/// Ergebnis der Flow-Revalidierung. +/// +public record RevalidateFlowResultDTO( + int UpdatedCount, + int CompleteCount, + int IncompleteCount); diff --git a/src/CryptoTracker.Client/Shared/LotSelector.razor b/src/CryptoTracker.Client/Shared/LotSelector.razor index 24abbf2..95162a6 100644 --- a/src/CryptoTracker.Client/Shared/LotSelector.razor +++ b/src/CryptoTracker.Client/Shared/LotSelector.razor @@ -40,13 +40,29 @@ { var allocation = GetAllocation(lot.Id); var isSelected = allocation > 0; -
+ var flowClass = lot.IsFlowComplete ? "" : "lot-incomplete-flow"; +
Lot #@lot.Id
- - @(lot.IsAltbestand ? "ALTBESTAND" : "Neubestand") - +
+ + @(lot.IsAltbestand ? "ALTBESTAND" : "Neubestand") + + @if (!lot.IsFlowComplete) + { + + Flow unvollständig + + } +
+ @if (!lot.IsFlowComplete && !string.IsNullOrEmpty(lot.FlowIncompleteReason)) + { +
+ + @lot.FlowIncompleteReason +
+ }
@@ -73,9 +89,12 @@ step="any" value="@allocation" @onchange="e => SetAllocation(lot.Id, ParseDecimal(e.Value))" - class="lot-input" /> + class="lot-input" + disabled="@(!lot.IsFlowComplete && !AllowIncompleteFlow)" /> @Symbol -
@@ -141,6 +160,11 @@ [Parameter] public bool ShowTaxPreview { get; set; } = false; [Parameter] public decimal? SalePriceEur { get; set; } [Parameter] public DateTimeOffset? MaxAcquisitionDate { get; set; } + /// + /// Wenn true, können auch Lots mit unvollständigem Flow ausgewählt werden. + /// Standardmäßig false - nur vollständig nachvollziehbare Lots sind wählbar. + /// + [Parameter] public bool AllowIncompleteFlow { get; set; } = false; [Parameter] public EventCallback> SelectionChanged { get; set; } [Parameter] public ILotsApi? LotsApi { get; set; } @@ -222,17 +246,21 @@ lots = lots.Where(l => l.AcquisitionDate < MaxAcquisitionDate.Value).ToList(); } - // Sortierung: Altbestand zuerst wenn priorisiert + // Sortierung: Vollständiger Flow zuerst, dann Altbestand wenn priorisiert, dann nach Datum if (PrioritizeAltbestand) { AvailableLots = lots - .OrderByDescending(l => l.IsAltbestand) + .OrderByDescending(l => l.IsFlowComplete) // Vollständiger Flow zuerst + .ThenByDescending(l => l.IsAltbestand) .ThenBy(l => l.AcquisitionDate) .ToList(); } else { - AvailableLots = lots.OrderBy(l => l.AcquisitionDate).ToList(); + AvailableLots = lots + .OrderByDescending(l => l.IsFlowComplete) // Vollständiger Flow zuerst + .ThenBy(l => l.AcquisitionDate) + .ToList(); } } finally @@ -252,6 +280,12 @@ var lot = AvailableLots.FirstOrDefault(l => l.Id == lotId); if (lot == null) return; + // Blockiere Lots mit unvollständigem Flow wenn nicht erlaubt + if (!lot.IsFlowComplete && !AllowIncompleteFlow) + { + return; + } + quantity = Math.Max(0, Math.Min(quantity, lot.RemainingQuantity)); if (quantity > 0) @@ -276,7 +310,12 @@ Allocations.Clear(); // Only apply allocations for lots that are in our filtered AvailableLots list - var availableLotIds = AvailableLots.Select(l => l.Id).ToHashSet(); + // and have complete flow (unless AllowIncompleteFlow is true) + var availableLotIds = AvailableLots + .Where(l => l.IsFlowComplete || AllowIncompleteFlow) + .Select(l => l.Id) + .ToHashSet(); + foreach (var alloc in suggestion.Allocations) { if (availableLotIds.Contains(alloc.LotId)) diff --git a/src/CryptoTracker/Controllers/LotsController.cs b/src/CryptoTracker/Controllers/LotsController.cs index efc6db5..0abceb9 100644 --- a/src/CryptoTracker/Controllers/LotsController.cs +++ b/src/CryptoTracker/Controllers/LotsController.cs @@ -11,11 +11,13 @@ namespace CryptoTracker.Controllers; public class LotsController : ControllerBase, ILotsApi { private readonly LotService _lotService; + private readonly LotFlowValidator _flowValidator; private readonly CryptoTrackerDbContext _dbContext; - public LotsController(LotService lotService, CryptoTrackerDbContext dbContext) + public LotsController(LotService lotService, LotFlowValidator flowValidator, CryptoTrackerDbContext dbContext) { _lotService = lotService; + _flowValidator = flowValidator; _dbContext = dbContext; } @@ -153,6 +155,22 @@ public async Task SellLots([FromBody] SellLotsRequest request) result.EstimatedKESt); } + [HttpPost("TransformLotsViaSwap")] + public async Task TransformLotsViaSwap([FromBody] TransformLotsViaSwapRequest request) + { + var allocations = request.SourceAllocations + .Select(a => new LotAllocation { LotId = a.LotId, Quantity = a.Quantity }) + .ToList(); + + var newLot = await _lotService.TransformLotsViaSwapAsync( + request.SellTradeId, + request.BuyTradeId, + allocations, + request.ResultingQuantity); + + return MapToDTO(newLot); + } + #endregion #region Auto-FIFO @@ -245,6 +263,60 @@ public async Task GenerateLotsFromExistingData([FromBody] #endregion + #region Flow-Validierung + + [HttpGet("ValidateLotFlow")] + public async Task ValidateLotFlow([FromQuery] int lotId) + { + var result = await _flowValidator.ValidateLotFlowAsync(lotId); + return new LotFlowValidationDTO( + result.LotId, + result.Symbol, + result.Quantity, + result.AcquisitionDate, + result.IsComplete, + result.IncompleteReason, + result.FlowChain.Select(s => new LotFlowStepDTO( + s.LotId, + s.Symbol, + s.Quantity, + s.Type.ToString(), + s.TradeId, + s.TransactionId, + s.DateTime)).ToList(), + result.EffectiveQuantity); + } + + [HttpPost("RevalidateAllLotFlows")] + public async Task RevalidateAllLotFlows() + { + var updatedCount = await _flowValidator.ValidateAndUpdateAllLotsAsync(); + + var completeCount = await _dbContext.AssetLots + .Where(l => l.RemainingQuantity > 0 && l.IsFlowComplete) + .CountAsync(); + var incompleteCount = await _dbContext.AssetLots + .Where(l => l.RemainingQuantity > 0 && !l.IsFlowComplete) + .CountAsync(); + + return new RevalidateFlowResultDTO(updatedCount, completeCount, incompleteCount); + } + + [HttpGet("GetLotsWithIncompleteFlow")] + public async Task> GetLotsWithIncompleteFlow([FromQuery] string? walletName = null) + { + int? walletId = null; + if (!string.IsNullOrWhiteSpace(walletName)) + { + walletId = await GetWalletIdByNameAsync(walletName); + } + + var lots = await _flowValidator.GetLotsWithIncompleteFlowAsync(walletId); + return lots.Select(MapToDTO).ToList(); + } + + #endregion + #region ILotsApi Implementation Task> ILotsApi.GetAvailableLotsAsync(string walletName, string symbol) @@ -300,7 +372,9 @@ Task ILotsApi.GenerateLotsFromExistingDataAsync(GenerateL lot.Note, lot.ParentLotId, lot.SourceTradeId, - lot.SourceTransactionId); + lot.SourceTransactionId, + lot.IsFlowComplete, + lot.FlowIncompleteReason); #endregion } diff --git a/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs b/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs index 677f770..eebbe36 100644 --- a/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs +++ b/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs @@ -128,6 +128,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(l => l.ChildLots) .HasForeignKey(l => l.ParentLotId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(l => l.TransformedToLot) + .WithMany(l => l.TransformedFromLots) + .HasForeignKey(l => l.TransformedToLotId) + .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasIndex(l => new { l.CurrentWalletId, l.Symbol }); modelBuilder.Entity() @@ -192,6 +197,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany() .HasForeignKey(t => t.ResultingLotId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(t => t.SourceLot) + .WithMany() + .HasForeignKey(t => t.SourceLotId) + .OnDelete(DeleteBehavior.Restrict); base.OnModelCreating(modelBuilder); } diff --git a/src/CryptoTracker/Entities/AssetLot.cs b/src/CryptoTracker/Entities/AssetLot.cs index 40bd214..5f7c281 100644 --- a/src/CryptoTracker/Entities/AssetLot.cs +++ b/src/CryptoTracker/Entities/AssetLot.cs @@ -81,6 +81,31 @@ public class AssetLot ///
public ICollection Movements { get; set; } = new List(); + // === Flow-Tracking === + + /// + /// Ist der gesamte Flow dieses Lots vollständig nachvollziehbar? + /// Nur wenn true, kann das Lot für steuerliche Zwecke verwendet werden. + /// + public bool IsFlowComplete { get; set; } = false; + + /// + /// Grund warum der Flow unvollständig ist (falls IsFlowComplete = false) + /// + public string? FlowIncompleteReason { get; set; } + + /// + /// Bei Crypto-zu-Crypto Swap: Das Lot wurde in ein anderes Asset transformiert. + /// Referenz auf das neue Lot (z.B. BTC -> ETH Swap: BTC-Lot verweist auf ETH-Lot) + /// + public int? TransformedToLotId { get; set; } + public AssetLot? TransformedToLot { get; set; } + + /// + /// Lots die durch Transformation in dieses Lot eingegangen sind + /// + public ICollection TransformedFromLots { get; set; } = new List(); + /// /// Benutzernotiz zur Herkunft /// diff --git a/src/CryptoTracker/Entities/CryptoTrade.cs b/src/CryptoTracker/Entities/CryptoTrade.cs index 38b353d..1862ed4 100644 --- a/src/CryptoTracker/Entities/CryptoTrade.cs +++ b/src/CryptoTracker/Entities/CryptoTrade.cs @@ -72,6 +72,13 @@ public class CryptoTrade : IFlow public int? ResultingLotId { get; set; } public AssetLot? ResultingLot { get; set; } + /// + /// Bei Swap (Sell-Seite): Welches Lot wird für diesen Trade verwendet? + /// Ermöglicht die Verknüpfung der Source-Lots mit dem Swap. + /// + public int? SourceLotId { get; set; } + public AssetLot? SourceLot { get; set; } + /// /// Wurde die Lot-Zuordnung für diesen Trade bestätigt? /// Bei Fiat-Kauf automatisch true, bei Verkauf/Swap muss User Lots auswählen. diff --git a/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.Designer.cs b/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.Designer.cs new file mode 100644 index 0000000..d3cca13 --- /dev/null +++ b/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.Designer.cs @@ -0,0 +1,1139 @@ +// +using System; +using CryptoTracker; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CryptoTracker.Migrations +{ + [DbContext(typeof(CryptoTrackerDbContext))] + [Migration("20260131122534_AddLotFlowTracking")] + partial class AddLotFlowTracking + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcquisitionDate") + .HasColumnType("datetimeoffset"); + + b.Property("AcquisitionPriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("AcquisitionType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CurrentWalletId") + .HasColumnType("int"); + + b.Property("FlowIncompleteReason") + .HasColumnType("nvarchar(max)"); + + b.Property("IsFlowComplete") + .HasColumnType("bit"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalQuantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("ParentLotId") + .HasColumnType("int"); + + b.Property("RemainingQuantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("SourceTradeId") + .HasColumnType("int"); + + b.Property("SourceTransactionId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TotalAcquisitionCostEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("TransformedToLotId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AcquisitionDate"); + + b.HasIndex("ParentLotId"); + + b.HasIndex("SourceTradeId"); + + b.HasIndex("SourceTransactionId"); + + b.HasIndex("TransformedToLotId"); + + b.HasIndex("CurrentWalletId", "Symbol"); + + b.ToTable("AssetLots"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Comment") + .HasColumnType("nvarchar(max)"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("Fee") + .HasColumnType("decimal(27, 12)"); + + b.Property("ForeignFee") + .HasColumnType("decimal(27, 12)"); + + b.Property("ForeignFeeSymbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + + b.Property("OppositeSymbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OppositeTradeId") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("decimal(27, 12)"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("Referenz") + .HasColumnType("nvarchar(max)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("SourceLotId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TradeType") + .HasColumnType("int"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OppositeTradeId") + .IsUnique() + .HasFilter("[OppositeTradeId] IS NOT NULL"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("SourceLotId"); + + b.HasIndex("WalletId"); + + b.ToTable("CryptoTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasColumnType("nvarchar(max)"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("Fee") + .HasColumnType("decimal(27, 12)"); + + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + + b.Property("Network") + .HasColumnType("nvarchar(max)"); + + b.Property("OppositeTransactionId") + .HasColumnType("int"); + + b.Property("OppositeWalletId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionId") + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .HasColumnType("int"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OppositeTransactionId") + .IsUnique() + .HasFilter("[OppositeTransactionId] IS NOT NULL"); + + b.HasIndex("OppositeWalletId"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("WalletId"); + + b.ToTable("CryptoTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TXID") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BinanceDeposits"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Executed") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fee") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pair") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.Property("Side") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BinanceTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TXID") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BinanceWithdrawals"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Adresse") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CryptoNachGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("CryptoVorGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("Datum") + .HasColumnType("datetimeoffset"); + + b.Property("EinheitKurs") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EinheitMengeNachGebuehr") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EinheitMengeVorGebuehr") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Kommentar") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Kontostand") + .HasColumnType("decimal(18,2)"); + + b.Property("Kurs") + .HasColumnType("decimal(18,2)"); + + b.Property("MengeNachGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("MengeVorGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("Referenz") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Typ") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Waehrung") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.Property("ZuAbgang") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BitcoinDeTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AmountAsset") + .HasColumnType("decimal(18,2)"); + + b.Property("AmountFiat") + .HasColumnType("decimal(18,2)"); + + b.Property("Asset") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AssetClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AssetMarketPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("AssetMarketPriceCurrency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fee") + .HasColumnType("decimal(18,2)"); + + b.Property("FeeAsset") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fiat") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InOut") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProductID") + .HasColumnType("int"); + + b.Property("Spread") + .HasColumnType("decimal(18,2)"); + + b.Property("SpreadCurrency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TaxFiat") + .HasColumnType("decimal(18,2)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("TransactionId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BitpandaTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Datum") + .HasColumnType("datetimeoffset"); + + b.Property("Kommentar") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("Typ") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("LedgerTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Executed") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fee") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pair") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Side") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Tradingplatform") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("MetamaskTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Datum") + .HasColumnType("datetimeoffset"); + + b.Property("Kommentar") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("Typ") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("MetamaskTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TXID") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("OkxDeposits"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Executed") + .HasColumnType("decimal(18,2)"); + + b.Property("Pair") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Side") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("OkxTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("IsTaxFree") + .HasColumnType("bit"); + + b.Property("LotId") + .HasColumnType("int"); + + b.Property("MovementType") + .HasColumnType("int"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("RealizedGainEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("SalePriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("TaxFreeReason") + .HasColumnType("int"); + + b.Property("TradeId") + .HasColumnType("int"); + + b.Property("TransactionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DateTime"); + + b.HasIndex("LotId"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("TradeId"); + + b.HasIndex("TransactionId"); + + b.ToTable("LotMovements"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.ManualCoinPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("PriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Symbol", "Date") + .IsUnique(); + + b.ToTable("ManualCoinPrices"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "CurrentWallet") + .WithMany() + .HasForeignKey("CurrentWalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ParentLot") + .WithMany("ChildLots") + .HasForeignKey("ParentLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTrade", "SourceTrade") + .WithMany() + .HasForeignKey("SourceTradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "SourceTransaction") + .WithMany() + .HasForeignKey("SourceTransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "TransformedToLot") + .WithMany("TransformedFromLots") + .HasForeignKey("TransformedToLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CurrentWallet"); + + b.Navigation("ParentLot"); + + b.Navigation("SourceTrade"); + + b.Navigation("SourceTransaction"); + + b.Navigation("TransformedToLot"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.HasOne("CryptoTracker.Entities.CryptoTrade", "OppositeTrade") + .WithOne() + .HasForeignKey("CryptoTracker.Entities.CryptoTrade", "OppositeTradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "SourceLot") + .WithMany() + .HasForeignKey("SourceLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OppositeTrade"); + + b.Navigation("ResultingLot"); + + b.Navigation("SourceLot"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "OppositeTransaction") + .WithOne() + .HasForeignKey("CryptoTracker.Entities.CryptoTransaction", "OppositeTransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.Wallet", "OppositeWallet") + .WithMany() + .HasForeignKey("OppositeWalletId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OppositeTransaction"); + + b.Navigation("OppositeWallet"); + + b.Navigation("ResultingLot"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => + { + b.HasOne("CryptoTracker.Entities.AssetLot", "Lot") + .WithMany("Movements") + .HasForeignKey("LotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTrade", "Trade") + .WithMany("LotMovements") + .HasForeignKey("TradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "Transaction") + .WithMany("LotMovements") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Lot"); + + b.Navigation("ResultingLot"); + + b.Navigation("Trade"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.Navigation("ChildLots"); + + b.Navigation("Movements"); + + b.Navigation("TransformedFromLots"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.Navigation("LotMovements"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.Navigation("LotMovements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.cs b/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.cs new file mode 100644 index 0000000..b38d634 --- /dev/null +++ b/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CryptoTracker.Migrations +{ + /// + public partial class AddLotFlowTracking : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SourceLotId", + table: "CryptoTrades", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "FlowIncompleteReason", + table: "AssetLots", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsFlowComplete", + table: "AssetLots", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TransformedToLotId", + table: "AssetLots", + type: "int", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_CryptoTrades_SourceLotId", + table: "CryptoTrades", + column: "SourceLotId"); + + migrationBuilder.CreateIndex( + name: "IX_AssetLots_TransformedToLotId", + table: "AssetLots", + column: "TransformedToLotId"); + + migrationBuilder.AddForeignKey( + name: "FK_AssetLots_AssetLots_TransformedToLotId", + table: "AssetLots", + column: "TransformedToLotId", + principalTable: "AssetLots", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_CryptoTrades_AssetLots_SourceLotId", + table: "CryptoTrades", + column: "SourceLotId", + principalTable: "AssetLots", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AssetLots_AssetLots_TransformedToLotId", + table: "AssetLots"); + + migrationBuilder.DropForeignKey( + name: "FK_CryptoTrades_AssetLots_SourceLotId", + table: "CryptoTrades"); + + migrationBuilder.DropIndex( + name: "IX_CryptoTrades_SourceLotId", + table: "CryptoTrades"); + + migrationBuilder.DropIndex( + name: "IX_AssetLots_TransformedToLotId", + table: "AssetLots"); + + migrationBuilder.DropColumn( + name: "SourceLotId", + table: "CryptoTrades"); + + migrationBuilder.DropColumn( + name: "FlowIncompleteReason", + table: "AssetLots"); + + migrationBuilder.DropColumn( + name: "IsFlowComplete", + table: "AssetLots"); + + migrationBuilder.DropColumn( + name: "TransformedToLotId", + table: "AssetLots"); + } + } +} diff --git a/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs b/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs index b49b0e7..79735de 100644 --- a/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs +++ b/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs @@ -45,6 +45,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CurrentWalletId") .HasColumnType("int"); + b.Property("FlowIncompleteReason") + .HasColumnType("nvarchar(max)"); + + b.Property("IsFlowComplete") + .HasColumnType("bit"); + b.Property("Note") .HasColumnType("nvarchar(max)"); @@ -70,6 +76,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TotalAcquisitionCostEur") .HasColumnType("decimal(27, 12)"); + b.Property("TransformedToLotId") + .HasColumnType("int"); + b.HasKey("Id"); b.HasIndex("AcquisitionDate"); @@ -80,6 +89,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SourceTransactionId"); + b.HasIndex("TransformedToLotId"); + b.HasIndex("CurrentWalletId", "Symbol"); b.ToTable("AssetLots"); @@ -131,6 +142,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ResultingLotId") .HasColumnType("int"); + b.Property("SourceLotId") + .HasColumnType("int"); + b.Property("Symbol") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -149,6 +163,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ResultingLotId"); + b.HasIndex("SourceLotId"); + b.HasIndex("WalletId"); b.ToTable("CryptoTrades"); @@ -874,6 +890,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("SourceTransactionId") .OnDelete(DeleteBehavior.Restrict); + b.HasOne("CryptoTracker.Entities.AssetLot", "TransformedToLot") + .WithMany("TransformedFromLots") + .HasForeignKey("TransformedToLotId") + .OnDelete(DeleteBehavior.Restrict); + b.Navigation("CurrentWallet"); b.Navigation("ParentLot"); @@ -881,6 +902,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SourceTrade"); b.Navigation("SourceTransaction"); + + b.Navigation("TransformedToLot"); }); modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => @@ -895,6 +918,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("ResultingLotId") .OnDelete(DeleteBehavior.Restrict); + b.HasOne("CryptoTracker.Entities.AssetLot", "SourceLot") + .WithMany() + .HasForeignKey("SourceLotId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") .WithMany() .HasForeignKey("WalletId") @@ -905,6 +933,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ResultingLot"); + b.Navigation("SourceLot"); + b.Navigation("Wallet"); }); @@ -1087,6 +1117,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ChildLots"); b.Navigation("Movements"); + + b.Navigation("TransformedFromLots"); }); modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => diff --git a/src/CryptoTracker/Services/LotFlowValidator.cs b/src/CryptoTracker/Services/LotFlowValidator.cs new file mode 100644 index 0000000..4a279a3 --- /dev/null +++ b/src/CryptoTracker/Services/LotFlowValidator.cs @@ -0,0 +1,412 @@ +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CryptoTracker.Services; + +/// +/// Validiert ob ein Lot einen vollständig nachvollziehbaren Flow hat. +/// Nur Lots mit vollständigem Flow können für steuerliche Zwecke verwendet werden. +/// +public class LotFlowValidator +{ + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILogger _logger; + + private static readonly string[] FiatSymbols = { "EUR", "USD", "CHF", "GBP", "ZEUR", "ZUSD", "eur", "usd", "chf", "gbp", "zeur", "zusd" }; + + public LotFlowValidator( + CryptoTrackerDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Validiert den Flow eines Lots und gibt das Ergebnis zurück. + /// + public async Task ValidateLotFlowAsync(int lotId) + { + var lot = await _dbContext.AssetLots + .Include(l => l.CurrentWallet) + .Include(l => l.ParentLot) + .Include(l => l.SourceTrade) + .Include(l => l.SourceTransaction) + .Include(l => l.TransformedFromLots) + .FirstOrDefaultAsync(l => l.Id == lotId); + + if (lot == null) + { + return new LotFlowValidationResult + { + IsComplete = false, + IncompleteReason = $"Lot #{lotId} nicht gefunden" + }; + } + + return await ValidateLotFlowInternalAsync(lot, new HashSet()); + } + + /// + /// Validiert den Flow eines Lots rekursiv. + /// + private async Task ValidateLotFlowInternalAsync(AssetLot lot, HashSet visitedLotIds) + { + // Zirkuläre Referenzen vermeiden + if (visitedLotIds.Contains(lot.Id)) + { + return new LotFlowValidationResult + { + IsComplete = false, + IncompleteReason = $"Zirkuläre Referenz bei Lot #{lot.Id} erkannt" + }; + } + visitedLotIds.Add(lot.Id); + + var result = new LotFlowValidationResult + { + LotId = lot.Id, + Symbol = lot.Symbol, + Quantity = lot.RemainingQuantity, + AcquisitionDate = lot.AcquisitionDate + }; + + // Schritt in die Kette hinzufügen + result.FlowChain.Add(new LotFlowStep + { + LotId = lot.Id, + Symbol = lot.Symbol, + Quantity = lot.OriginalQuantity, + Type = MapAcquisitionTypeToFlowStepType(lot.AcquisitionType), + TradeId = lot.SourceTradeId, + TransactionId = lot.SourceTransactionId, + DateTime = lot.AcquisitionDate + }); + + // Fall 1: Fiat-Kauf - Das ist der Ursprung, immer vollständig + if (lot.AcquisitionType == LotAcquisitionType.FiatPurchase) + { + result.IsComplete = true; + result.EffectiveQuantity = lot.RemainingQuantity; + return result; + } + + // Fall 2: Manuelle Einträge - Als vollständig betrachten (User hat Verantwortung) + if (lot.AcquisitionType == LotAcquisitionType.Manual) + { + result.IsComplete = true; + result.EffectiveQuantity = lot.RemainingQuantity; + return result; + } + + // Fall 3: Mining, Staking, Airdrop, etc. - Als vollständig betrachten (Anschaffung zu Zeitwert) + if (lot.AcquisitionType == LotAcquisitionType.Mining || + lot.AcquisitionType == LotAcquisitionType.Staking || + lot.AcquisitionType == LotAcquisitionType.Lending || + lot.AcquisitionType == LotAcquisitionType.Airdrop || + lot.AcquisitionType == LotAcquisitionType.Hardfork || + lot.AcquisitionType == LotAcquisitionType.Gift) + { + result.IsComplete = true; + result.EffectiveQuantity = lot.RemainingQuantity; + return result; + } + + // Fall 4: Interner Transfer - Prüfe Parent-Lot + if (lot.AcquisitionType == LotAcquisitionType.InternalTransfer) + { + if (lot.ParentLotId == null) + { + result.IsComplete = false; + result.IncompleteReason = $"Interner Transfer ohne Parent-Lot (Lot #{lot.Id})"; + return result; + } + + // Prüfe ob die Transfer-Transaktion vollständig gepaart ist + if (lot.SourceTransactionId.HasValue) + { + var transaction = await _dbContext.CryptoTransactions + .Include(t => t.OppositeTransaction) + .FirstOrDefaultAsync(t => t.Id == lot.SourceTransactionId.Value); + + if (transaction?.OppositeTransactionId == null) + { + result.IsComplete = false; + result.IncompleteReason = $"Transfer-Transaktion #{lot.SourceTransactionId} hat keine Gegentransaktion"; + return result; + } + } + + // Rekursiv das Parent-Lot validieren + var parentLot = await _dbContext.AssetLots + .Include(l => l.CurrentWallet) + .Include(l => l.ParentLot) + .Include(l => l.SourceTrade) + .Include(l => l.SourceTransaction) + .FirstOrDefaultAsync(l => l.Id == lot.ParentLotId.Value); + + if (parentLot == null) + { + result.IsComplete = false; + result.IncompleteReason = $"Parent-Lot #{lot.ParentLotId} nicht gefunden"; + return result; + } + + var parentResult = await ValidateLotFlowInternalAsync(parentLot, visitedLotIds); + result.FlowChain.AddRange(parentResult.FlowChain); + result.IsComplete = parentResult.IsComplete; + result.IncompleteReason = parentResult.IncompleteReason; + result.EffectiveQuantity = parentResult.IsComplete ? lot.RemainingQuantity : 0; + return result; + } + + // Fall 5: Crypto-Swap - Prüfe ob Trade gepaart ist und Parent-Lot validieren + if (lot.AcquisitionType == LotAcquisitionType.CryptoSwap) + { + // Prüfe ob der Source-Trade existiert und einen OppositeTrade hat + if (!lot.SourceTradeId.HasValue) + { + result.IsComplete = false; + result.IncompleteReason = $"Crypto-Swap Lot #{lot.Id} hat keinen Source-Trade"; + return result; + } + + var trade = await _dbContext.CryptoTrades + .Include(t => t.OppositeTrade) + .FirstOrDefaultAsync(t => t.Id == lot.SourceTradeId.Value); + + if (trade == null) + { + result.IsComplete = false; + result.IncompleteReason = $"Source-Trade #{lot.SourceTradeId} nicht gefunden"; + return result; + } + + if (trade.OppositeTradeId == null) + { + result.IsComplete = false; + result.IncompleteReason = $"Swap-Trade #{trade.Id} hat keinen Gegentrade (OppositeTrade fehlt)"; + return result; + } + + // Prüfe ob Lots aus der Swap-Quelle kommen (TransformedFromLots) + if (lot.TransformedFromLots == null || !lot.TransformedFromLots.Any()) + { + // Alternativ: Prüfe ParentLot + if (lot.ParentLotId == null) + { + result.IsComplete = false; + result.IncompleteReason = $"Crypto-Swap Lot #{lot.Id} hat keine Source-Lots (TransformedFromLots und ParentLot fehlen)"; + return result; + } + } + + // Validiere die Source-Lots + var sourceLots = lot.TransformedFromLots?.ToList() ?? new List(); + if (lot.ParentLotId.HasValue && !sourceLots.Any(l => l.Id == lot.ParentLotId.Value)) + { + var parentLot = await _dbContext.AssetLots + .FirstOrDefaultAsync(l => l.Id == lot.ParentLotId.Value); + if (parentLot != null) + { + sourceLots.Add(parentLot); + } + } + + foreach (var sourceLot in sourceLots) + { + var sourceResult = await ValidateLotFlowInternalAsync(sourceLot, visitedLotIds); + result.FlowChain.AddRange(sourceResult.FlowChain); + + if (!sourceResult.IsComplete) + { + result.IsComplete = false; + result.IncompleteReason = sourceResult.IncompleteReason; + return result; + } + } + + result.IsComplete = true; + result.EffectiveQuantity = lot.RemainingQuantity; + return result; + } + + // Fall 6: Externe Einzahlung - Ohne weitere Dokumentation unvollständig + if (lot.AcquisitionType == LotAcquisitionType.ExternalDeposit) + { + // Prüfe ob die Transaktion eine Gegentransaktion hat (dann ist es eigentlich ein interner Transfer) + if (lot.SourceTransactionId.HasValue) + { + var transaction = await _dbContext.CryptoTransactions + .Include(t => t.OppositeTransaction) + .FirstOrDefaultAsync(t => t.Id == lot.SourceTransactionId.Value); + + if (transaction?.OppositeTransactionId != null) + { + // Hat Gegentransaktion - sollte als InternalTransfer klassifiziert werden + result.IsComplete = false; + result.IncompleteReason = $"Externe Einzahlung #{lot.Id} hat Gegentransaktion - sollte als InternalTransfer klassifiziert werden"; + return result; + } + } + + // Externe Einzahlung ohne Nachweis + result.IsComplete = false; + result.IncompleteReason = $"Externe Einzahlung #{lot.Id} ohne vollständige Herkunftsdokumentation"; + return result; + } + + // Unbekannter Fall + result.IsComplete = false; + result.IncompleteReason = $"Unbekannter Akquisitionstyp: {lot.AcquisitionType}"; + return result; + } + + /// + /// Validiert alle Lots und aktualisiert deren IsFlowComplete-Status. + /// + public async Task ValidateAndUpdateAllLotsAsync() + { + var lots = await _dbContext.AssetLots + .Where(l => l.RemainingQuantity > 0) + .ToListAsync(); + + var updatedCount = 0; + + foreach (var lot in lots) + { + try + { + var result = await ValidateLotFlowAsync(lot.Id); + + if (lot.IsFlowComplete != result.IsComplete || lot.FlowIncompleteReason != result.IncompleteReason) + { + lot.IsFlowComplete = result.IsComplete; + lot.FlowIncompleteReason = result.IncompleteReason; + updatedCount++; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler bei der Validierung von Lot #{LotId}", lot.Id); + lot.IsFlowComplete = false; + lot.FlowIncompleteReason = $"Validierungsfehler: {ex.Message}"; + updatedCount++; + } + } + + if (updatedCount > 0) + { + await _dbContext.SaveChangesAsync(); + _logger.LogInformation("{Count} Lots wurden aktualisiert", updatedCount); + } + + return updatedCount; + } + + /// + /// Holt alle Lots die einen unvollständigen Flow haben. + /// + public async Task> GetLotsWithIncompleteFlowAsync(int? walletId = null) + { + var query = _dbContext.AssetLots + .AsNoTracking() + .Include(l => l.CurrentWallet) + .Where(l => l.RemainingQuantity > 0 && !l.IsFlowComplete); + + if (walletId.HasValue) + { + query = query.Where(l => l.CurrentWalletId == walletId.Value); + } + + return await query.OrderBy(l => l.AcquisitionDate).ToListAsync(); + } + + private static LotFlowStepType MapAcquisitionTypeToFlowStepType(LotAcquisitionType acquisitionType) + { + return acquisitionType switch + { + LotAcquisitionType.FiatPurchase => LotFlowStepType.FiatPurchase, + LotAcquisitionType.CryptoSwap => LotFlowStepType.Swap, + LotAcquisitionType.InternalTransfer => LotFlowStepType.Transfer, + LotAcquisitionType.ExternalDeposit => LotFlowStepType.ExternalDeposit, + LotAcquisitionType.Mining => LotFlowStepType.Mining, + LotAcquisitionType.Staking => LotFlowStepType.Staking, + LotAcquisitionType.Lending => LotFlowStepType.Lending, + LotAcquisitionType.Airdrop => LotFlowStepType.Airdrop, + LotAcquisitionType.Hardfork => LotFlowStepType.Hardfork, + LotAcquisitionType.Gift => LotFlowStepType.Gift, + LotAcquisitionType.Manual => LotFlowStepType.Manual, + _ => LotFlowStepType.Unknown + }; + } +} + +#region Result-Klassen + +/// +/// Ergebnis der Flow-Validierung für ein Lot. +/// +public class LotFlowValidationResult +{ + public int LotId { get; set; } + public string Symbol { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public DateTimeOffset AcquisitionDate { get; set; } + + /// + /// Ist der Flow vollständig nachvollziehbar? + /// + public bool IsComplete { get; set; } + + /// + /// Grund für unvollständigen Flow. + /// + public string? IncompleteReason { get; set; } + + /// + /// Die Kette der Schritte bis zum Ursprung. + /// + public List FlowChain { get; set; } = new(); + + /// + /// Tatsächlich verfügbare Menge (nur bei vollständigem Flow > 0). + /// + public decimal EffectiveQuantity { get; set; } +} + +/// +/// Ein Schritt in der Flow-Kette. +/// +public class LotFlowStep +{ + public int LotId { get; set; } + public string Symbol { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public LotFlowStepType Type { get; set; } + public int? TradeId { get; set; } + public int? TransactionId { get; set; } + public DateTimeOffset DateTime { get; set; } +} + +/// +/// Art eines Flow-Schritts. +/// +public enum LotFlowStepType +{ + Unknown, + FiatPurchase, + Swap, + Transfer, + ExternalDeposit, + Mining, + Staking, + Lending, + Airdrop, + Hardfork, + Gift, + Manual +} + +#endregion diff --git a/src/CryptoTracker/Services/LotService.cs b/src/CryptoTracker/Services/LotService.cs index 5e3e322..7d9ed4d 100644 --- a/src/CryptoTracker/Services/LotService.cs +++ b/src/CryptoTracker/Services/LotService.cs @@ -31,7 +31,33 @@ public LotService( /// Holt alle verfügbaren (nicht vollständig verbrauchten) Lots für ein Wallet und Symbol. /// Sortiert nach FIFO (älteste zuerst). /// - public async Task> GetAvailableLotsAsync(int walletId, string symbol) + /// Wallet ID + /// Asset-Symbol + /// Nur Lots mit vollständigem Flow zurückgeben + public async Task> GetAvailableLotsAsync(int walletId, string symbol, bool onlyCompleteFlow = false) + { + var query = _dbContext.AssetLots + .AsNoTracking() + .Include(l => l.CurrentWallet) + .Where(l => l.CurrentWalletId == walletId + && l.Symbol == symbol.ToUpperInvariant() + && l.RemainingQuantity > 0); + + if (onlyCompleteFlow) + { + query = query.Where(l => l.IsFlowComplete); + } + + return await query + .OrderBy(l => l.AcquisitionDate) + .ToListAsync(); + } + + /// + /// Holt alle verfügbaren Lots für ein Wallet und Symbol (inkl. Flow-Status). + /// Gibt auch Lots mit unvollständigem Flow zurück, markiert diese aber entsprechend. + /// + public async Task> GetAllAvailableLotsWithFlowStatusAsync(int walletId, string symbol) { return await _dbContext.AssetLots .AsNoTracking() @@ -46,33 +72,43 @@ public async Task> GetAvailableLotsAsync(int walletId, string sy /// /// Holt alle verfügbaren Altbestand-Lots (vor 28.02.2021) für ein Wallet und Symbol. /// - public async Task> GetAltbestandLotsAsync(int walletId, string symbol) + public async Task> GetAltbestandLotsAsync(int walletId, string symbol, bool onlyCompleteFlow = false) { - return await _dbContext.AssetLots + var query = _dbContext.AssetLots .AsNoTracking() .Include(l => l.CurrentWallet) .Where(l => l.CurrentWalletId == walletId && l.Symbol == symbol.ToUpperInvariant() && l.RemainingQuantity > 0 - && l.AcquisitionDate <= AssetLot.AltbestandStichtag) - .OrderBy(l => l.AcquisitionDate) - .ToListAsync(); + && l.AcquisitionDate <= AssetLot.AltbestandStichtag); + + if (onlyCompleteFlow) + { + query = query.Where(l => l.IsFlowComplete); + } + + return await query.OrderBy(l => l.AcquisitionDate).ToListAsync(); } /// /// Holt alle verfügbaren Neubestand-Lots (ab 01.03.2021) für ein Wallet und Symbol. /// - public async Task> GetNeubestandLotsAsync(int walletId, string symbol) + public async Task> GetNeubestandLotsAsync(int walletId, string symbol, bool onlyCompleteFlow = false) { - return await _dbContext.AssetLots + var query = _dbContext.AssetLots .AsNoTracking() .Include(l => l.CurrentWallet) .Where(l => l.CurrentWalletId == walletId && l.Symbol == symbol.ToUpperInvariant() && l.RemainingQuantity > 0 - && l.AcquisitionDate > AssetLot.AltbestandStichtag) - .OrderBy(l => l.AcquisitionDate) - .ToListAsync(); + && l.AcquisitionDate > AssetLot.AltbestandStichtag); + + if (onlyCompleteFlow) + { + query = query.Where(l => l.IsFlowComplete); + } + + return await query.OrderBy(l => l.AcquisitionDate).ToListAsync(); } /// @@ -371,6 +407,149 @@ public async Task SellLotsAsync( #endregion + #region Swap-Transformation + + /// + /// Transformiert Lots durch einen Crypto-zu-Crypto-Swap. + /// Erstellt neue Lots für das Ziel-Asset und verknüpft sie mit den Quell-Lots. + /// + /// Die Sell-Seite des Swaps (gibt Crypto ab) + /// Die Buy-Seite des Swaps (erhält Crypto) + /// Lot-Zuordnungen für die Quell-Coins + /// Menge der erhaltenen Coins (nach Gebühren) + public async Task TransformLotsViaSwapAsync( + int sellTradeId, + int buyTradeId, + IList sourceAllocations, + decimal resultingQuantity) + { + var sellTrade = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == sellTradeId) + ?? throw new ArgumentException($"Sell-Trade {sellTradeId} nicht gefunden"); + + var buyTrade = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == buyTradeId) + ?? throw new ArgumentException($"Buy-Trade {buyTradeId} nicht gefunden"); + + if (sellTrade.TradeType != TradeType.Sell) + throw new ArgumentException("sellTradeId muss auf einen Sell-Trade verweisen"); + if (buyTrade.TradeType != TradeType.Buy) + throw new ArgumentException("buyTradeId muss auf einen Buy-Trade verweisen"); + + // Prüfen ob Trades ein Swap-Paar sind + if (sellTrade.OppositeTradeId != buyTradeId || buyTrade.OppositeTradeId != sellTradeId) + { + throw new InvalidOperationException( + "Die Trades sind kein gültiges Swap-Paar (OppositeTradeId stimmt nicht überein)"); + } + + // Berechne gewichteten durchschnittlichen Anschaffungspreis der Quell-Lots + decimal totalSourceQuantity = 0; + decimal totalSourceCost = 0; + DateTimeOffset earliestAcquisitionDate = DateTimeOffset.MaxValue; + var sourceLotIds = new List(); + + foreach (var allocation in sourceAllocations) + { + var lot = await _dbContext.AssetLots.FindAsync(allocation.LotId) + ?? throw new ArgumentException($"Lot {allocation.LotId} nicht gefunden"); + + if (lot.RemainingQuantity < allocation.Quantity) + throw new InvalidOperationException( + $"Lot #{lot.Id} hat nur {lot.RemainingQuantity} verfügbar, aber {allocation.Quantity} angefordert"); + + // Quell-Lot reduzieren + lot.RemainingQuantity -= allocation.Quantity; + + totalSourceQuantity += allocation.Quantity; + totalSourceCost += lot.AcquisitionPriceEur * allocation.Quantity; + + if (lot.AcquisitionDate < earliestAcquisitionDate) + { + earliestAcquisitionDate = lot.AcquisitionDate; + } + + sourceLotIds.Add(lot.Id); + + // Movement erstellen für den Swap-Out + var movement = new LotMovement + { + LotId = lot.Id, + Quantity = allocation.Quantity, + DateTime = sellTrade.DateTime, + MovementType = LotMovementType.CryptoSwapOut, + TradeId = sellTradeId, + IsTaxFree = true, // Krypto-zu-Krypto Swaps sind in Österreich steuerfrei (Tausch) + TaxFreeReason = TaxFreeReason.CryptoSwap + }; + _dbContext.LotMovements.Add(movement); + } + + // Neues Lot für das Ziel-Asset erstellen + // Anschaffungskosten werden proportional übertragen + var acquisitionPricePerUnit = totalSourceQuantity > 0 + ? totalSourceCost / resultingQuantity + : 0; + + var newLot = new AssetLot + { + Symbol = buyTrade.Symbol.ToUpperInvariant(), + CurrentWalletId = buyTrade.WalletId, + RemainingQuantity = resultingQuantity, + OriginalQuantity = resultingQuantity, + // WICHTIG: Kaufdatum des ältesten Quell-Lots übernehmen (für Altbestand-Berechnung) + AcquisitionDate = earliestAcquisitionDate, + AcquisitionPriceEur = acquisitionPricePerUnit, + TotalAcquisitionCostEur = totalSourceCost, + AcquisitionType = LotAcquisitionType.CryptoSwap, + SourceTradeId = buyTradeId, + // Bei mehreren Quell-Lots: ParentLot auf das erste setzen, TransformedFromLots für alle + ParentLotId = sourceLotIds.FirstOrDefault(), + Note = $"Swap von {totalSourceQuantity} {sellTrade.Symbol}" + }; + + _dbContext.AssetLots.Add(newLot); + await _dbContext.SaveChangesAsync(); + + // Quell-Lots mit TransformedToLotId verknüpfen + foreach (var lotId in sourceLotIds) + { + var sourceLot = await _dbContext.AssetLots.FindAsync(lotId); + if (sourceLot != null) + { + sourceLot.TransformedToLotId = newLot.Id; + } + } + + // Bewegungen mit neuem Lot verknüpfen + var movements = await _dbContext.LotMovements + .Where(m => m.TradeId == sellTradeId && m.ResultingLotId == null) + .ToListAsync(); + foreach (var movement in movements) + { + movement.ResultingLotId = newLot.Id; + } + + // Trades als zugeordnet markieren + sellTrade.LotAssignmentConfirmed = true; + sellTrade.SourceLotId = sourceLotIds.FirstOrDefault(); + + buyTrade.LotAssignmentConfirmed = true; + buyTrade.ResultingLotId = newLot.Id; + + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation( + "Swap-Transformation abgeschlossen: {SourceQty} {SourceSymbol} -> {TargetQty} {TargetSymbol} (Lot #{NewLotId})", + totalSourceQuantity, sellTrade.Symbol, resultingQuantity, buyTrade.Symbol, newLot.Id); + + return newLot; + } + + #endregion + #region Auto-FIFO /// diff --git a/src/CryptoTracker/Startup.cs b/src/CryptoTracker/Startup.cs index 3b95ca1..0915592 100644 --- a/src/CryptoTracker/Startup.cs +++ b/src/CryptoTracker/Startup.cs @@ -67,6 +67,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/CryptoTracker/wwwroot/app.css b/src/CryptoTracker/wwwroot/app.css index faf948c..16ab7f2 100644 --- a/src/CryptoTracker/wwwroot/app.css +++ b/src/CryptoTracker/wwwroot/app.css @@ -1203,3 +1203,152 @@ h1, h2, h3, .app-title { } } +/* ============================================ + LOT FLOW TRACKING STYLES + ============================================ */ + +/* Incomplete flow badge */ +.flow-incomplete-badge { + background: rgba(194, 70, 84, 0.15); + color: var(--rose-500); +} + +/* Lot item with incomplete flow */ +.lot-item.lot-incomplete-flow { + opacity: 0.7; + background: repeating-linear-gradient( + 45deg, + var(--surface-200), + var(--surface-200) 10px, + var(--surface-300) 10px, + var(--surface-300) 20px + ); +} + +.lot-item.lot-incomplete-flow .lot-input, +.lot-item.lot-incomplete-flow .btn { + cursor: not-allowed; +} + +/* Flow warning message */ +.lot-flow-warning { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.5rem; + margin-bottom: 0.5rem; + background: rgba(194, 70, 84, 0.08); + border-radius: var(--radius-sm); + font-size: 0.75rem; + color: var(--rose-500); +} + +.flow-warning-icon { + font-size: 0.9rem; +} + +/* Multiple badges container */ +.lot-badges { + display: flex; + gap: 0.35rem; + align-items: center; + flex-wrap: wrap; +} + +/* Lot Flow Overview Component */ +.lot-flow-overview { + background: var(--surface-100); + border: 1px solid var(--surface-300); + border-radius: var(--radius-md); + padding: 1rem; +} + +.lot-flow-chain { + display: flex; + flex-direction: column; + gap: 0; +} + +.lot-flow-step { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: var(--surface-200); + border-radius: var(--radius-sm); + position: relative; +} + +.lot-flow-step:not(:last-child)::after { + content: ''; + position: absolute; + left: 1.35rem; + bottom: -12px; + width: 2px; + height: 12px; + background: var(--surface-300); +} + +.lot-flow-step-icon { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + flex-shrink: 0; +} + +.lot-flow-step-icon.fiat-purchase { + background: rgba(31, 143, 106, 0.15); + color: var(--mint-500); +} + +.lot-flow-step-icon.swap { + background: rgba(13, 110, 253, 0.15); + color: #0d6efd; +} + +.lot-flow-step-icon.transfer { + background: rgba(47, 52, 58, 0.15); + color: var(--ink-700); +} + +.lot-flow-step-icon.external { + background: rgba(194, 70, 84, 0.15); + color: var(--rose-500); +} + +.lot-flow-step-icon.reward { + background: rgba(255, 193, 7, 0.2); + color: #a26d00; +} + +.lot-flow-step-info { + flex: 1; +} + +.lot-flow-step-type { + font-weight: 600; + font-size: 0.85rem; + color: var(--ink-900); +} + +.lot-flow-step-details { + font-size: 0.75rem; + color: var(--ink-500); +} + +.lot-flow-break { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + background: rgba(194, 70, 84, 0.08); + border-radius: var(--radius-sm); + color: var(--rose-500); + font-size: 0.8rem; + font-weight: 500; +} + From a279ed7245fd9b936d43f80e2dd8645cc3e27407 Mon Sep 17 00:00:00 2001 From: "t.humer" Date: Sat, 31 Jan 2026 22:03:46 +0100 Subject: [PATCH 3/7] Add AI Linking Entities and Transaction Metadata - Introduced AgentMemory and TransactionLinkMetadata entities to enhance transaction linking capabilities. - Implemented AgentMemoryType enum for categorizing memory entries. - Added IsIntentionallyUnlinked column to CryptoTransactions for better tracking of unlinked transactions. - Created migration scripts to set up the new database schema and relationships. --- infra/appservice/appservice.bicep | 44 + infra/main.bicep | 26 + infra/main.parameters.json | 18 + plans/ai-assisted-linking.md | 1564 +++++++++++++++++ .../Pages/ImportPages/ImportPageBase.razor | 2 +- .../Pages/LotsLinking.razor | 420 +++++ .../Pages/LotsLinking.razor.cs | 362 ++++ .../Pages/TransactionLinking.razor | 416 +++++ .../Pages/TransactionLinking.razor.cs | 455 +++++ .../Pages/Transaktionen.razor.cs | 137 ++ .../RestClients/DataImportRestClient.cs | 13 +- .../Shared/Api/IDataImportApi.cs | 3 +- .../Shared/Api/ITransactionLinkingApi.cs | 57 + .../Shared/LinkingWizard.razor | 278 +++ .../Shared/LinkingWizard.razor.cs | 142 ++ .../Shared/LotFlowVisualization.razor | 139 ++ .../Shared/TransactionLinkingDTOs.cs | 131 ++ .../AutoImport/MetamaskAutoImportTests.cs | 2 +- .../TransactionHelperTests.cs | 160 ++ .../Agent/Common/AILinkingAgentBuilder.cs | 105 ++ .../Agent/Common/AgentDefinitionBase.cs | 21 + .../Agent/Common/IAgentDefinition.cs | 38 + src/CryptoTracker/Agent/Common/IAgentTool.cs | 29 + .../Agent/Common/OpenAISettings.cs | 43 + .../TransactionLinkingAgentDefinition.cs | 109 ++ .../Services/TransactionLinkingService.cs | 334 ++++ .../Tools/FindMatchingTransactionsTool.cs | 156 ++ .../Agent/Tools/GetAgentMemoryTool.cs | 72 + .../Agent/Tools/GetTransactionDetailsTool.cs | 90 + .../Tools/GetUnlinkedTransactionsTool.cs | 94 + .../Agent/Tools/LinkTransactionsTool.cs | 115 ++ .../Tools/MarkAsIntentionallyUnlinkedTool.cs | 87 + .../Agent/Tools/SaveAgentMemoryTool.cs | 96 + .../Components/Layout/NavMenu.razor | 2 + .../Controllers/DataImportController.cs | 18 +- .../TransactionLinkingController.cs | 286 +++ src/CryptoTracker/CryptoTracker.csproj | 17 +- .../DbContext/CryptoTrackerDbContext.cs | 25 + src/CryptoTracker/Entities/AgentMemory.cs | 79 + .../Entities/CryptoTransaction.cs | 13 + .../Entities/TransactionLinkMetadata.cs | 49 + .../Entities/TransactionLinkType.cs | 46 + .../Import/BitpandaTransactionImporter.cs | 20 +- ...131133250_AddAILinkingEntities.Designer.cs | 1238 +++++++++++++ .../20260131133250_AddAILinkingEntities.cs | 107 ++ .../CryptoTrackerDbContextModelSnapshot.cs | 105 ++ .../Services/DataImportService.cs | 60 +- .../Services/FinanceValueProvider.cs | 19 +- .../Services/TransactionService.cs | 6 +- src/CryptoTracker/Startup.cs | 64 + src/CryptoTracker/wwwroot/app.css | 1370 +++++++++++++++ 51 files changed, 9222 insertions(+), 60 deletions(-) create mode 100644 plans/ai-assisted-linking.md create mode 100644 src/CryptoTracker.Client/Pages/LotsLinking.razor create mode 100644 src/CryptoTracker.Client/Pages/LotsLinking.razor.cs create mode 100644 src/CryptoTracker.Client/Pages/TransactionLinking.razor create mode 100644 src/CryptoTracker.Client/Pages/TransactionLinking.razor.cs create mode 100644 src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs create mode 100644 src/CryptoTracker.Client/Shared/LinkingWizard.razor create mode 100644 src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs create mode 100644 src/CryptoTracker.Client/Shared/LotFlowVisualization.razor create mode 100644 src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs create mode 100644 src/CryptoTracker.Tests/TransactionHelperTests.cs create mode 100644 src/CryptoTracker/Agent/Common/AILinkingAgentBuilder.cs create mode 100644 src/CryptoTracker/Agent/Common/AgentDefinitionBase.cs create mode 100644 src/CryptoTracker/Agent/Common/IAgentDefinition.cs create mode 100644 src/CryptoTracker/Agent/Common/IAgentTool.cs create mode 100644 src/CryptoTracker/Agent/Common/OpenAISettings.cs create mode 100644 src/CryptoTracker/Agent/Definitions/TransactionLinkingAgentDefinition.cs create mode 100644 src/CryptoTracker/Agent/Services/TransactionLinkingService.cs create mode 100644 src/CryptoTracker/Agent/Tools/FindMatchingTransactionsTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/GetAgentMemoryTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/GetTransactionDetailsTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/GetUnlinkedTransactionsTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/LinkTransactionsTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/MarkAsIntentionallyUnlinkedTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/SaveAgentMemoryTool.cs create mode 100644 src/CryptoTracker/Controllers/TransactionLinkingController.cs create mode 100644 src/CryptoTracker/Entities/AgentMemory.cs create mode 100644 src/CryptoTracker/Entities/TransactionLinkMetadata.cs create mode 100644 src/CryptoTracker/Entities/TransactionLinkType.cs create mode 100644 src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.Designer.cs create mode 100644 src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.cs diff --git a/infra/appservice/appservice.bicep b/infra/appservice/appservice.bicep index b318755..1a2a393 100644 --- a/infra/appservice/appservice.bicep +++ b/infra/appservice/appservice.bicep @@ -26,6 +26,25 @@ param identityProviderClientId string @description('Name of the Azure AD application used for authentication') param identityProviderName string +// OpenAI Configuration +@description('Azure OpenAI Endpoint URL') +param openAiEndpoint string = '' + +@description('Main deployment name for complex agent conversations') +param openAiDeploymentName string = '' + +@description('Fast deployment name for batch classification') +param openAiFastDeploymentName string = '' + +@description('Embedding deployment name for similarity search') +param openAiEmbeddingDeploymentName string = '' + +@description('Embedding vector dimensions') +param openAiEmbeddingVectorDimensions int = 1536 + +@description('Key Vault reference to the OpenAI API key') +param openAiKeyKVUri string = '' + resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { name: applicationInsightsName scope: resourceGroup() @@ -55,6 +74,31 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = { name: 'COINMARKETCAP_API_KEY' value: '@Microsoft.KeyVault(SecretUri=${coinmarketcapApiKeyKVUri})' } + // OpenAI Configuration + { + name: 'OpenAiEndpoint' + value: openAiEndpoint + } + { + name: 'OpenAiDeploymentName' + value: openAiDeploymentName + } + { + name: 'OpenAiFastDeploymentName' + value: openAiFastDeploymentName + } + { + name: 'OpenAiEmbeddingDeploymentName' + value: openAiEmbeddingDeploymentName + } + { + name: 'OpenAiEmbeddingVectorDimensions' + value: string(openAiEmbeddingVectorDimensions) + } + { + name: 'OpenAiKey' + value: openAiKeyKVUri != '' ? '@Microsoft.KeyVault(SecretUri=${openAiKeyKVUri})' : '' + } ] connectionStrings: [ { diff --git a/infra/main.bicep b/infra/main.bicep index eb07387..f1c9db3 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -24,6 +24,25 @@ param identityProviderName string @description('Azure AD application (client) ID for authentication') param identityProviderClientId string +// OpenAI Configuration +@description('Azure OpenAI Endpoint URL') +param openAiEndpoint string = '' + +@description('Main deployment name for complex agent conversations') +param openAiDeploymentName string = '' + +@description('Fast deployment name for batch classification') +param openAiFastDeploymentName string = '' + +@description('Embedding deployment name for similarity search') +param openAiEmbeddingDeploymentName string = '' + +@description('Embedding vector dimensions') +param openAiEmbeddingVectorDimensions int = 1536 + +@description('Key Vault reference to the OpenAI API key') +param openAiKeyKVUri string = '' + var tags = { 'azd-env-name': environmentName } resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' existing = { @@ -54,6 +73,13 @@ module appService 'appservice/appservice.bicep' = { tenantId: tenant().tenantId identityProviderName: identityProviderName identityProviderClientId: identityProviderClientId + // OpenAI Configuration + openAiEndpoint: openAiEndpoint + openAiDeploymentName: openAiDeploymentName + openAiFastDeploymentName: openAiFastDeploymentName + openAiEmbeddingDeploymentName: openAiEmbeddingDeploymentName + openAiEmbeddingVectorDimensions: openAiEmbeddingVectorDimensions + openAiKeyKVUri: openAiKeyKVUri } } diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 840658a..68cc933 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -22,6 +22,24 @@ }, "identityProviderClientId": { "value": "ffa07cd3-dd3b-44e1-8968-6e95c5185423" + }, + "openAiEndpoint": { + "value": "https://stockmanager-swedencentral.cognitiveservices.azure.com/" + }, + "openAiDeploymentName": { + "value": "gpt-5.2" + }, + "openAiFastDeploymentName": { + "value": "gpt-5-nano" + }, + "openAiEmbeddingDeploymentName": { + "value": "text-embedding-3-large" + }, + "openAiEmbeddingVectorDimensions": { + "value": 1536 + }, + "openAiKeyKVUri": { + "value": "" } } } diff --git a/plans/ai-assisted-linking.md b/plans/ai-assisted-linking.md new file mode 100644 index 0000000..408729d --- /dev/null +++ b/plans/ai-assisted-linking.md @@ -0,0 +1,1564 @@ +# Plan: KI-gestützte Transaktions- und Lot-Verknüpfung + +## Zusammenfassung + +Dieses Dokument beschreibt die Implementierung eines KI-gestützten Assistenten-Systems für: +1. **Transaktionsverknüpfung**: Intelligente Paarung von Send/Receive-Transaktionen +2. **Lot-Verknüpfung**: Verkettung von Asset-Lots für vollständige Steuerketten + +Das System nutzt **Azure OpenAI** mit dem **Microsoft Agent Framework** für intelligente Entscheidungen und bietet eine interaktive Wizard-UI für Benutzerbestätigungen. + +--- + +## 1. Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Blazor UI │ +│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ TransaktionsPairing │ │ LotVerknüpfung │ │ Wizard-Ansicht │ │ +│ │ .razor │ │ .razor │ │ (Interaktiv) │ │ +│ └──────────┬──────────┘ └──────────┬──────────┘ └──────────┬──────────┘ │ +└─────────────┼───────────────────────┼───────────────────────┼───────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ API Controller Layer │ +│ ┌─────────────────────────────┐ ┌─────────────────────────────────────┐ │ +│ │ TransactionLinkingController│ │ LotLinkingController │ │ +│ └──────────────┬──────────────┘ └──────────────┬──────────────────────┘ │ +└─────────────────┼────────────────────────────────┼──────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Agent Services │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ AILinkingAgentBuilder │ │ +│ │ ┌────────────────────────┐ ┌────────────────────────────────────┐ │ │ +│ │ │TransactionLinkingAgent │ │ LotLinkingAgent │ │ │ +│ │ │ - Tools │ │ - Tools │ │ │ +│ │ │ - Prompts │ │ - Prompts │ │ │ +│ │ │ - Memory (DB) │ │ - Memory (DB) │ │ │ +│ │ └────────────────────────┘ └────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Azure OpenAI │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐ │ +│ │ gpt-5.2 │ │ gpt-5-nano │ │ text-embedding-3-large │ │ +│ │ (Hauptmodell) │ │ (Fast) │ │ (Embeddings) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. NuGet-Pakete + +Basierend auf dem StockManager-Projekt werden folgende Pakete benötigt: + +```xml + + + + + + + + + + + + +``` + +> **Hinweis**: Immer die neuesten Preview-Versionen von NuGet.org verwenden! Oben sind die Versionen aus StockManager (Stand Januar 2026). + +--- + +## 3. Datenmodell-Erweiterungen + +### 3.1 Neue Entity: `TransactionLinkMetadata` + +Speichert Metadaten zur Verknüpfung von Transaktionen. + +```csharp +namespace CryptoTracker.Entities; + +/// +/// Flags für die Art der Transaktionsverknüpfung +/// +[Flags] +public enum TransactionLinkType +{ + None = 0, + + /// Exakte Zeit & Betrag Übereinstimmung + TimeAndAmount = 1, + + /// Via LLM/KI-Analyse verknüpft + AIAssisted = 2, + + /// Direkte Verknüpfung (gleiche TxId, Adresse) + Direct = 4, + + /// Indirekte Verknüpfung (Kommentar, Wallet-Name) + Indirect = 8, + + /// Automatisch vom System verknüpft + Automatic = 16, + + /// Manuell vom Benutzer verknüpft + Manual = 32, + + /// Bewusst ohne Gegenstück gelassen (z.B. Staking Rewards) + IntentionallyUnlinked = 64 +} + +/// +/// Metadaten zur Transaktionsverknüpfung +/// +public class TransactionLinkMetadata +{ + public int Id { get; set; } + + /// + /// Die verknüpfte Transaktion (Send oder Receive) + /// + public int TransactionId { get; set; } + public CryptoTransaction Transaction { get; set; } = null!; + + /// + /// Art der Verknüpfung (Flags) + /// + public TransactionLinkType LinkType { get; set; } + + /// + /// Konfidenz der Verknüpfung (0.0 - 1.0) + /// + public decimal Confidence { get; set; } + + /// + /// Begründung für die Verknüpfung (vom LLM oder System) + /// + public string? Reason { get; set; } + + /// + /// Wurde vom Benutzer bestätigt? + /// + public bool IsConfirmed { get; set; } + + /// + /// Zeitpunkt der Verknüpfung + /// + public DateTimeOffset LinkedAt { get; set; } + + /// + /// Zeitpunkt der Benutzerbestätigung + /// + public DateTimeOffset? ConfirmedAt { get; set; } +} +``` + +### 3.2 Neue Entity: `AgentMemory` + +Persistenter Speicher für Agent-Entscheidungen und Regeln. + +```csharp +namespace CryptoTracker.Entities; + +/// +/// Typ des Agent-Gedächtniseintrags +/// +public enum AgentMemoryType +{ + /// Kommentar-Muster zum Überspringen (z.B. "ETH 2.0 Staking Rewards") + SkipPattern, + + /// Bekannte Wallet-Zuordnung (z.B. "PC-Wallet Thomas PC" → WalletId) + WalletMapping, + + /// Bekannte Adress-Zuordnung + AddressMapping, + + /// Benutzer-Entscheidung für ähnliche Fälle + UserDecision, + + /// Erkanntes Importfehler-Muster + ImportErrorPattern +} + +/// +/// Persistentes Gedächtnis für den Linking-Agent +/// +public class AgentMemory +{ + public int Id { get; set; } + + /// + /// Agent-Schlüssel (z.B. "transaction-linking", "lot-linking") + /// + public string AgentKey { get; set; } = string.Empty; + + /// + /// Typ des Eintrags + /// + public AgentMemoryType MemoryType { get; set; } + + /// + /// Schlüssel für den Eintrag (z.B. Kommentar-Pattern, Adresse) + /// + public string Key { get; set; } = string.Empty; + + /// + /// Wert/Payload (JSON oder einfacher String) + /// + public string Value { get; set; } = string.Empty; + + /// + /// Beschreibung/Begründung + /// + public string? Description { get; set; } + + /// + /// Erstellt am + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Wie oft wurde diese Regel angewendet? + /// + public int UsageCount { get; set; } +} +``` + +### 3.3 Erweiterung: `CryptoTransaction` + +```csharp +// Neue Properties in CryptoTransaction.cs + +/// +/// Metadaten zur Verknüpfung +/// +public TransactionLinkMetadata? LinkMetadata { get; set; } + +/// +/// Wurde bewusst ohne Gegenstück gelassen? +/// +public bool IsIntentionallyUnlinked { get; set; } +``` + +--- + +## 4. Agent-Implementierung + +### 4.1 Verzeichnisstruktur + +``` +src/CryptoTracker/ +├── Agent/ +│ ├── Common/ +│ │ ├── IAgentDefinition.cs +│ │ ├── IAgentTool.cs +│ │ ├── AgentDefinitionBase.cs +│ │ └── AILinkingAgentBuilder.cs +│ ├── Definitions/ +│ │ ├── TransactionLinkingAgentDefinition.cs +│ │ └── LotLinkingAgentDefinition.cs +│ ├── Tools/ +│ │ ├── GetUnlinkedTransactionsTool.cs +│ │ ├── GetTransactionDetailsTool.cs +│ │ ├── LinkTransactionsTool.cs +│ │ ├── MarkAsIntentionallyUnlinkedTool.cs +│ │ ├── SaveAgentMemoryTool.cs +│ │ ├── GetAgentMemoryTool.cs +│ │ ├── GetUnlinkedLotsTool.cs +│ │ ├── GetLotDetailsTool.cs +│ │ └── LinkLotsTool.cs +│ └── Services/ +│ ├── TransactionLinkingService.cs +│ └── LotLinkingService.cs +``` + +### 4.2 Interface: `IAgentTool` + +```csharp +namespace CryptoTracker.Agent.Common; + +public interface IAgentTool +{ + /// + /// Gibt den Delegate zurück, der vom Agent aufgerufen wird + /// + Delegate GetToolRunner(); + + /// + /// Name des Tools (für Function Calling) + /// + string GetToolName(); + + /// + /// Beschreibung des Tools (für LLM-Kontext) + /// + string GetToolDescription(); + + /// + /// Optional: JsonSerializerContext für komplexe Typen + /// + JsonSerializerContext? GetJsonSerializerContext() => null; +} +``` + +### 4.3 Interface: `IAgentDefinition` + +```csharp +namespace CryptoTracker.Agent.Common; + +public record AgentMetadata( + string Key, + string DisplayName, + string Description +); + +public record AgentPromptDefinition( + string SystemPrompt +); + +public interface IAgentDefinition +{ + AgentMetadata Metadata { get; } + AgentPromptDefinition PromptDefinition { get; } + IReadOnlyList Tools { get; } +} +``` + +### 4.4 Transaction Linking Agent Definition + +```csharp +namespace CryptoTracker.Agent.Definitions; + +public sealed class TransactionLinkingAgentDefinition : AgentDefinitionBase +{ + public const string KEY = "transaction-linking"; + + private const string SystemPrompt = """ +ROLLE +Du bist ein Experte für Kryptowährungs-Transaktionsanalyse. Deine Aufgabe ist es, +Send- und Receive-Transaktionen intelligent zu verknüpfen. + +KONTEXT +- Transaktionen zwischen eigenen Wallets haben oft leicht unterschiedliche Zeiten + (Blockchain-Bestätigungszeit) +- Der Betrag nach Gebühren (QuantityAfterFee) beim Send sollte dem Receive entsprechen +- Kommentare können Hinweise auf die Herkunft geben + +REGELN FÜR VERKNÜPFUNGEN +1. **Externe Einnahmen (NICHT verknüpfen)**: + - "Staking Rewards", "ETH 2.0 Staking Rewards" → Externe Einnahme, kein Gegenstück + - "Airdrop", "Bonus", "Referral" → Externe Einnahme + - "Mining", "Lending Interest" → Externe Einnahme + - "div. Käufe", "Kauf" → Kommt von Fiat-Kauf, kein Transfer-Gegenstück + +2. **Interne Transfers (VERKNÜPFEN)**: + - Kommentare wie "PC-Wallet", "Ledger", Wallet-Namen → Interner Transfer + - Gleiche Adresse in verschiedenen Wallets → Wahrscheinlich verknüpft + - Zeit innerhalb von ~30 Minuten + gleicher Betrag → Hohe Wahrscheinlichkeit + +3. **Fehlende Gegenstücke**: + - Wenn ein Send keinen passenden Receive hat, könnte das Wallet nicht importiert sein + - Wenn ein Receive von einer bekannten eigenen Adresse kommt, aber kein Send existiert, + schlage vor, dass das Quell-Wallet fehlt + +IMPORTFEHLER ERKENNEN +- UTC vs. Lokalzeit-Differenzen (z.B. +1h, +2h Unterschied) +- Wenn Zeit um genau 1-2 Stunden abweicht, aber Betrag exakt passt → Zeitzone-Problem + +TOOLS +- Verwende `get_unlinked_transactions` um unverknüpfte Transaktionen zu laden +- Verwende `get_transaction_details` für Details zu einer Transaktion +- Verwende `link_transactions` um zwei Transaktionen zu verknüpfen +- Verwende `mark_intentionally_unlinked` für Transaktionen ohne Gegenstück +- Verwende `save_memory` um Regeln zu speichern (z.B. "Überspringe alle Staking Rewards") +- Verwende `get_memory` um gespeicherte Regeln abzurufen + +WORKFLOW +1. Lade zunächst gespeicherte Regeln (get_memory) +2. Lade unverknüpfte Transaktionen blockweise (get_unlinked_transactions mit limit/offset) +3. Analysiere jede Transaktion: + - Prüfe ob bekannte Skip-Patterns zutreffen + - Suche nach passenden Gegenstücken + - Bei Unsicherheit: Frage den Benutzer +4. Speichere neue Regeln wenn der Benutzer eine wiederkehrende Entscheidung trifft + +OUTPUT +Antworte immer auf Deutsch. Erkläre deine Entscheidungen kurz und prägnant. +Bei Rückfragen an den Benutzer, formuliere klare Ja/Nein-Fragen oder Multiple-Choice. +"""; + + public TransactionLinkingAgentDefinition( + GetUnlinkedTransactionsTool getUnlinkedTool, + GetTransactionDetailsTool getDetailsTool, + LinkTransactionsTool linkTool, + MarkAsIntentionallyUnlinkedTool markUnlinkedTool, + SaveAgentMemoryTool saveMemoryTool, + GetAgentMemoryTool getMemoryTool) + : base( + new AgentMetadata(KEY, "Transaktions-Verknüpfungs-Assistent", + "Verknüpft Send/Receive-Transaktionen intelligent"), + new AgentPromptDefinition(SystemPrompt), + [getUnlinkedTool, getDetailsTool, linkTool, markUnlinkedTool, saveMemoryTool, getMemoryTool]) + { + } +} +``` + +### 4.5 Beispiel-Tool: `GetUnlinkedTransactionsTool` + +```csharp +namespace CryptoTracker.Agent.Tools; + +public sealed class GetUnlinkedTransactionsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public GetUnlinkedTransactionsTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => GetUnlinkedTransactionsAsync; + public string GetToolName() => "get_unlinked_transactions"; + public string GetToolDescription() => """ + Lädt unverknüpfte Transaktionen. + Parameter: + - type: "send", "receive" oder "all" + - symbol: Optional, z.B. "ETH", "BTC" + - limit: Max. Anzahl (default 50) + - offset: Für Paginierung + - includeIntentionallyUnlinked: false = nur wirklich unverknüpfte + Gibt JSON-Array mit Id, DateTime, Symbol, Quantity, QuantityAfterFee, Comment, Address, WalletName zurück. + """; + + private async Task GetUnlinkedTransactionsAsync( + [Description("Filter: 'send', 'receive' oder 'all'")] string type = "all", + [Description("Symbol-Filter, z.B. 'ETH'")] string? symbol = null, + [Description("Max. Anzahl Ergebnisse")] int limit = 50, + [Description("Offset für Paginierung")] int offset = 0, + [Description("Auch bewusst unverknüpfte einschließen")] bool includeIntentionallyUnlinked = false) + { + var query = _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.OppositeTransactionId == null); + + if (!includeIntentionallyUnlinked) + query = query.Where(t => !t.IsIntentionallyUnlinked); + + if (type == "send") + query = query.Where(t => t.TransactionType == TransactionType.Send); + else if (type == "receive") + query = query.Where(t => t.TransactionType == TransactionType.Receive); + + if (!string.IsNullOrEmpty(symbol)) + query = query.Where(t => t.Symbol == symbol); + + var transactions = await query + .OrderBy(t => t.DateTime) + .Skip(offset) + .Take(limit) + .Select(t => new + { + t.Id, + DateTime = t.DateTime.ToString("yyyy-MM-dd HH:mm:ss"), + Type = t.TransactionType.ToString(), + t.Symbol, + t.Quantity, + t.QuantityAfterFee, + t.Comment, + t.Address, + t.TransactionId, + WalletName = t.Wallet.Name + }) + .ToListAsync(); + + return JsonSerializer.Serialize(transactions); + } +} +``` + +### 4.6 Beispiel-Tool: `LinkTransactionsTool` + +```csharp +namespace CryptoTracker.Agent.Tools; + +public sealed class LinkTransactionsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public LinkTransactionsTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => LinkTransactionsAsync; + public string GetToolName() => "link_transactions"; + public string GetToolDescription() => """ + Verknüpft zwei Transaktionen miteinander (Send mit Receive). + Parameter: + - sendTransactionId: ID der Send-Transaktion + - receiveTransactionId: ID der Receive-Transaktion + - linkType: Flags wie die Verknüpfung zustande kam (TimeAndAmount=1, AIAssisted=2, Direct=4, Indirect=8, Automatic=16) + - confidence: Konfidenz 0.0-1.0 + - reason: Begründung für die Verknüpfung + Gibt Erfolg/Fehler-Status zurück. + """; + + private async Task LinkTransactionsAsync( + [Description("ID der Send-Transaktion")] int sendTransactionId, + [Description("ID der Receive-Transaktion")] int receiveTransactionId, + [Description("Link-Typ Flags (1=TimeAndAmount, 2=AIAssisted, 4=Direct, 8=Indirect, 16=Automatic)")] int linkType, + [Description("Konfidenz 0.0-1.0")] decimal confidence, + [Description("Begründung")] string reason) + { + var send = await _dbContext.CryptoTransactions.FindAsync(sendTransactionId); + var receive = await _dbContext.CryptoTransactions.FindAsync(receiveTransactionId); + + if (send == null || receive == null) + return JsonSerializer.Serialize(new { success = false, error = "Transaktion nicht gefunden" }); + + if (send.TransactionType != TransactionType.Send) + return JsonSerializer.Serialize(new { success = false, error = "Erste Transaktion muss Send sein" }); + + if (receive.TransactionType != TransactionType.Receive) + return JsonSerializer.Serialize(new { success = false, error = "Zweite Transaktion muss Receive sein" }); + + // Verknüpfung erstellen + send.OppositeTransactionId = receive.Id; + send.OppositeWalletId = receive.WalletId; + receive.OppositeTransactionId = send.Id; + receive.OppositeWalletId = send.WalletId; + + // Metadaten speichern + var metadata = new TransactionLinkMetadata + { + TransactionId = send.Id, + LinkType = (TransactionLinkType)linkType, + Confidence = confidence, + Reason = reason, + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = false // Muss noch vom Benutzer bestätigt werden + }; + _dbContext.TransactionLinkMetadata.Add(metadata); + + await _dbContext.SaveChangesAsync(); + + return JsonSerializer.Serialize(new { + success = true, + message = $"Transaktionen {sendTransactionId} und {receiveTransactionId} verknüpft" + }); + } +} +``` + +### 4.7 `AILinkingAgentBuilder` + +```csharp +namespace CryptoTracker.Agent.Common; + +/// +/// Builder für AI-Agents mit Multi-Model-Unterstützung +/// +public class AILinkingAgentBuilder +{ + private readonly IServiceProvider _serviceProvider; + private readonly IOptions _settings; + + public AILinkingAgentBuilder(IOptions settings, IServiceProvider serviceProvider) + { + _settings = settings; + _serviceProvider = serviceProvider; + } + + /// + /// Erstellt einen Agent mit dem Hauptmodell (gpt-5.2) + /// Für komplexe Konversationen und Steuerberatung + /// + public AIAgent BuildAgent(string agentKey) + { + return BuildAgentWithModel(agentKey, _settings.Value.DeploymentName); + } + + /// + /// Erstellt einen Agent mit dem Fast-Modell (gpt-5-nano) + /// Für schnelle Batch-Klassifizierung und einfache Entscheidungen + /// + public AIAgent BuildFastAgent(string agentKey) + { + return BuildAgentWithModel(agentKey, _settings.Value.FastDeploymentName); + } + + private AIAgent BuildAgentWithModel(string agentKey, string deploymentName) + { + var scope = _serviceProvider.CreateScope(); + var openAiClient = scope.ServiceProvider.GetRequiredService(); + var definitions = scope.ServiceProvider.GetServices(); + + var definition = definitions.FirstOrDefault(d => d.Metadata.Key == agentKey) + ?? throw new ArgumentException($"Agent '{agentKey}' nicht gefunden"); + + var chatClient = openAiClient + .GetChatClient(deploymentName) + .AsIChatClient(); + + var tools = definition.Tools + .Select(t => AIFunctionFactory.Create( + t.GetToolRunner(), + t.GetToolName(), + t.GetToolDescription(), + t.GetJsonSerializerContext()?.Options)) + .ToArray(); + + return chatClient.AsAIAgent(new ChatClientAgentOptions + { + Name = definition.Metadata.Key, + Description = definition.Metadata.DisplayName, + ChatOptions = new ChatOptions + { + Instructions = definition.PromptDefinition.SystemPrompt, + Tools = tools + } + }); + } + + /// + /// Gibt einen Embedding-Client für Ähnlichkeitssuche zurück + /// + public EmbeddingClient GetEmbeddingClient() + { + var scope = _serviceProvider.CreateScope(); + var openAiClient = scope.ServiceProvider.GetRequiredService(); + return openAiClient.GetEmbeddingClient(_settings.Value.EmbeddingDeploymentName); + } +} +``` + +### 4.8 Beispiel: Fast-Agent für Batch-Klassifizierung + +```csharp +/// +/// Nutzt gpt-5-nano für schnelle Klassifizierung von Transaktionen +/// +public class TransactionClassificationService +{ + private readonly AILinkingAgentBuilder _agentBuilder; + + public async Task> ClassifyBatchAsync( + List transactions, + CancellationToken ct = default) + { + // Fast-Agent für schnelle Batch-Verarbeitung + var fastAgent = _agentBuilder.BuildFastAgent("transaction-classifier"); + + var results = new List(); + + // Batch von 10 Transaktionen pro Anfrage + foreach (var batch in transactions.Chunk(10)) + { + var prompt = $""" + Klassifiziere diese Transaktionen schnell: + {string.Join("\n", batch.Select(t => + $"- ID:{t.Id}, Typ:{t.TransactionType}, Comment:'{t.Comment}'"))} + + Antworte nur mit JSON: [{"id": 1, "type": "staking|transfer|external|unknown"}] + """; + + var result = await fastAgent.RunAsync(prompt, cancellationToken: ct); + // Parse result... + } + + return results; + } +} +``` + +--- + +## 5. Service-Schicht + +### 5.1 `TransactionLinkingService` + +```csharp +namespace CryptoTracker.Agent.Services; + +public class TransactionLinkingService +{ + private readonly AILinkingAgentBuilder _agentBuilder; + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILogger _logger; + + public TransactionLinkingService( + AILinkingAgentBuilder agentBuilder, + CryptoTrackerDbContext dbContext, + ILogger logger) + { + _agentBuilder = agentBuilder; + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Startet eine neue Linking-Session + /// + public async Task StartSessionAsync(CancellationToken ct = default) + { + var agent = _agentBuilder.BuildAgent(TransactionLinkingAgentDefinition.KEY); + + // Erste Nachricht: Agent analysiert die Situation + var result = await agent.RunAsync( + "Starte eine neue Verknüpfungs-Session. " + + "Lade zuerst deine gespeicherten Regeln und dann die unverknüpften Transaktionen. " + + "Gib mir einen Überblick über die Situation.", + cancellationToken: ct); + + return new LinkingSessionResult + { + AgentResponse = result.Text ?? "", + // Session-State könnte hier gespeichert werden + }; + } + + /// + /// Setzt eine Konversation mit dem Agent fort + /// + public async Task ContinueSessionAsync( + string userMessage, + CancellationToken ct = default) + { + var agent = _agentBuilder.BuildAgent(TransactionLinkingAgentDefinition.KEY); + + var result = await agent.RunAsync(userMessage, cancellationToken: ct); + + return new LinkingSessionResult + { + AgentResponse = result.Text ?? "" + }; + } + + /// + /// Führt automatische Verknüpfung durch (ohne Benutzerinteraktion) + /// + public async Task RunAutomaticLinkingAsync(CancellationToken ct = default) + { + var agent = _agentBuilder.BuildAgent(TransactionLinkingAgentDefinition.KEY); + + var result = await agent.RunAsync( + "Führe automatische Verknüpfung durch: " + + "1. Lade gespeicherte Regeln " + + "2. Verknüpfe alle Transaktionen wo Zeit und Betrag exakt passen " + + "3. Markiere bekannte externe Einnahmen (Staking, etc.) als intentionally unlinked " + + "4. Gib mir eine Zusammenfassung was verknüpft wurde", + cancellationToken: ct); + + // Zähle Ergebnisse + var linkedCount = await _dbContext.TransactionLinkMetadata + .CountAsync(m => m.LinkedAt > DateTimeOffset.UtcNow.AddMinutes(-5), ct); + + return new AutoLinkResult + { + LinkedCount = linkedCount, + Summary = result.Text ?? "" + }; + } +} + +public record LinkingSessionResult +{ + public string AgentResponse { get; init; } = ""; + public List? Proposals { get; init; } +} + +public record TransactionLinkProposal +{ + public int SendId { get; init; } + public int ReceiveId { get; init; } + public decimal Confidence { get; init; } + public string Reason { get; init; } = ""; +} + +public record AutoLinkResult +{ + public int LinkedCount { get; init; } + public string Summary { get; init; } = ""; +} +``` + +--- + +## 6. API Controller + +### 6.1 `TransactionLinkingController` + +```csharp +namespace CryptoTracker.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class TransactionLinkingController : ControllerBase +{ + private readonly TransactionLinkingService _linkingService; + private readonly CryptoTrackerDbContext _dbContext; + + public TransactionLinkingController( + TransactionLinkingService linkingService, + CryptoTrackerDbContext dbContext) + { + _linkingService = linkingService; + _dbContext = dbContext; + } + + /// + /// Startet eine neue Agent-Session + /// + [HttpPost("session/start")] + public async Task> StartSession(CancellationToken ct) + { + var result = await _linkingService.StartSessionAsync(ct); + return Ok(result); + } + + /// + /// Sendet eine Nachricht an den Agent + /// + [HttpPost("session/message")] + public async Task> SendMessage( + [FromBody] AgentMessageRequest request, + CancellationToken ct) + { + var result = await _linkingService.ContinueSessionAsync(request.Message, ct); + return Ok(result); + } + + /// + /// Führt automatische Verknüpfung durch + /// + [HttpPost("auto-link")] + public async Task> RunAutoLink(CancellationToken ct) + { + var result = await _linkingService.RunAutomaticLinkingAsync(ct); + return Ok(result); + } + + /// + /// Gibt alle unverknüpften Transaktionen zurück + /// + [HttpGet("unlinked")] + public async Task>> GetUnlinked( + [FromQuery] string? type = null, + [FromQuery] string? symbol = null, + CancellationToken ct = default) + { + var query = _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked); + + if (type == "send") + query = query.Where(t => t.TransactionType == TransactionType.Send); + else if (type == "receive") + query = query.Where(t => t.TransactionType == TransactionType.Receive); + + if (!string.IsNullOrEmpty(symbol)) + query = query.Where(t => t.Symbol == symbol); + + var transactions = await query + .OrderByDescending(t => t.DateTime) + .Select(t => new UnlinkedTransactionDto + { + Id = t.Id, + DateTime = t.DateTime, + Type = t.TransactionType.ToString(), + Symbol = t.Symbol, + Quantity = t.Quantity, + QuantityAfterFee = t.QuantityAfterFee, + Comment = t.Comment, + Address = t.Address, + WalletName = t.Wallet.Name + }) + .ToListAsync(ct); + + return Ok(transactions); + } + + /// + /// Manuell zwei Transaktionen verknüpfen + /// + [HttpPost("link")] + public async Task ManualLink([FromBody] ManualLinkRequest request, CancellationToken ct) + { + var send = await _dbContext.CryptoTransactions.FindAsync(request.SendId); + var receive = await _dbContext.CryptoTransactions.FindAsync(request.ReceiveId); + + if (send == null || receive == null) + return NotFound("Transaktion nicht gefunden"); + + send.OppositeTransactionId = receive.Id; + send.OppositeWalletId = receive.WalletId; + receive.OppositeTransactionId = send.Id; + receive.OppositeWalletId = send.WalletId; + + var metadata = new TransactionLinkMetadata + { + TransactionId = send.Id, + LinkType = TransactionLinkType.Manual, + Confidence = 1.0m, + Reason = request.Reason ?? "Manuell verknüpft", + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = true, + ConfirmedAt = DateTimeOffset.UtcNow + }; + _dbContext.TransactionLinkMetadata.Add(metadata); + + await _dbContext.SaveChangesAsync(ct); + return Ok(); + } + + /// + /// Bestätigt vorgeschlagene Verknüpfungen + /// + [HttpPost("confirm")] + public async Task ConfirmLinks([FromBody] ConfirmLinksRequest request, CancellationToken ct) + { + var metadataIds = request.TransactionIds; + var metadataList = await _dbContext.TransactionLinkMetadata + .Where(m => metadataIds.Contains(m.TransactionId)) + .ToListAsync(ct); + + foreach (var metadata in metadataList) + { + metadata.IsConfirmed = true; + metadata.ConfirmedAt = DateTimeOffset.UtcNow; + } + + await _dbContext.SaveChangesAsync(ct); + return Ok(new { confirmed = metadataList.Count }); + } +} + +public record AgentMessageRequest(string Message); +public record ManualLinkRequest(int SendId, int ReceiveId, string? Reason); +public record ConfirmLinksRequest(List TransactionIds); +``` + +--- + +## 7. Blazor UI + +### 7.1 Neue Seite: `TransaktionsPairing.razor` + +```razor +@page "/transaktionen/pairing" +@using CryptoTracker.Client.Shared +@inject HttpClient Http +@inject IJSRuntime JS + +Transaktions-Verknüpfung + +

Transaktions-Verknüpfungs-Assistent

+ +
+ +
+
+
+ KI-Assistent + +
+
+ @foreach (var message in _chatMessages) + { +
+
@message.Content
+
@message.Timestamp.ToString("HH:mm")
+
+ } + @if (_isLoading) + { +
+
+ Denke nach... +
+
+ } +
+ +
+ + +
+
Schnellaktionen
+
+ + +
+
+
+ + +
+
+
+ Unverknüpfte Transaktionen (@_unlinkedTransactions.Count) +
+
+ @if (_proposals.Any()) + { +
Vorgeschlagene Verknüpfungen
+ @foreach (var proposal in _proposals) + { +
+
+ Send #@proposal.SendId → Receive #@proposal.ReceiveId + @((proposal.Confidence * 100).ToString("0"))% +
+ @proposal.Reason +
+ + +
+
+ } + } + +
Unverknüpfte Transaktionen
+ + + + + + + + + + + + @foreach (var tx in _unlinkedTransactions.Take(20)) + { + + + + + + + + } + +
DatumTypSymbolMengeWallet
@tx.DateTime.ToString("dd.MM.yy HH:mm")@tx.Type@tx.Symbol@tx.QuantityAfterFee.ToString("0.####")@tx.WalletName
+
+
+
+
+ +@code { + private List _chatMessages = new(); + private List _unlinkedTransactions = new(); + private List _proposals = new(); + private UnlinkedTransactionDto? _selectedTransaction; + private string _userInput = ""; + private bool _isLoading; + + protected override async Task OnInitializedAsync() + { + await LoadUnlinkedTransactions(); + } + + private async Task StartNewSession() + { + _chatMessages.Clear(); + _isLoading = true; + + var result = await Http.PostAsJsonAsync("api/transactionlinking/session/start", new { }); + var response = await result.Content.ReadFromJsonAsync(); + + _chatMessages.Add(new ChatMessage + { + Content = response?.AgentResponse ?? "Session gestartet", + IsUser = false, + Timestamp = DateTime.Now + }); + + _isLoading = false; + } + + private async Task SendMessage() + { + if (string.IsNullOrWhiteSpace(_userInput)) return; + + _chatMessages.Add(new ChatMessage + { + Content = _userInput, + IsUser = true, + Timestamp = DateTime.Now + }); + + var message = _userInput; + _userInput = ""; + _isLoading = true; + + var result = await Http.PostAsJsonAsync("api/transactionlinking/session/message", + new AgentMessageRequest(message)); + var response = await result.Content.ReadFromJsonAsync(); + + _chatMessages.Add(new ChatMessage + { + Content = response?.AgentResponse ?? "Fehler", + IsUser = false, + Timestamp = DateTime.Now + }); + + if (response?.Proposals != null) + _proposals = response.Proposals; + + _isLoading = false; + await LoadUnlinkedTransactions(); + } + + private async Task RunAutoLink() + { + _isLoading = true; + var result = await Http.PostAsJsonAsync("api/transactionlinking/auto-link", new { }); + var response = await result.Content.ReadFromJsonAsync(); + + _chatMessages.Add(new ChatMessage + { + Content = $"Automatische Verknüpfung abgeschlossen: {response?.LinkedCount} Transaktionen verknüpft.\n\n{response?.Summary}", + IsUser = false, + Timestamp = DateTime.Now + }); + + _isLoading = false; + await LoadUnlinkedTransactions(); + } + + private async Task LoadUnlinkedTransactions() + { + _unlinkedTransactions = await Http.GetFromJsonAsync>( + "api/transactionlinking/unlinked") ?? new(); + } + + // ... weitere Methoden + + private record ChatMessage + { + public string Content { get; init; } = ""; + public bool IsUser { get; init; } + public DateTime Timestamp { get; init; } + } +} +``` + +--- + +## 8. Lot-Verknüpfungs-Assistent + +### 8.1 `LotLinkingAgentDefinition` + +```csharp +namespace CryptoTracker.Agent.Definitions; + +public sealed class LotLinkingAgentDefinition : AgentDefinitionBase +{ + public const string KEY = "lot-linking"; + + private const string SystemPrompt = """ +ROLLE +Du bist ein Experte für Krypto-Steuerrecht (Österreich) und Asset-Lot-Verwaltung. +Deine Aufgabe ist es, Lot-Ketten für die Steuerdokumentation zu vervollständigen. + +KONTEXT - ÖSTERREICHISCHE KRYPTO-STEUER +- Neubestand (ab 01.03.2021): 27,5% KESt bei Verkauf +- Altbestand (vor 28.02.2021): Steuerfrei nach 1 Jahr Haltefrist +- Krypto-zu-Krypto-Tausch: Steuerfrei, aber Anschaffungskosten werden weitergegeben +- Vollständige Lot-Ketten sind für den Nachweis beim Finanzamt erforderlich + +REGELN FÜR LOT-VERKNÜPFUNGEN +1. **Bei Transfers**: Das Lot wird auf die neue Wallet übertragen (ParentLotId setzen) +2. **Bei Swaps (Krypto→Krypto)**: + - Quell-Lots werden verbraucht (SourceLotId im Trade setzen) + - Neues Lot erbt Anschaffungskosten und -datum +3. **Bei Verkäufen (Krypto→Fiat)**: Lot wird verbraucht, Gewinn/Verlust berechnet +4. **FIFO vs. Benutzerauswahl**: Bei mehreren Lots fragt den Benutzer + +FLOW-VALIDIERUNG +Ein Lot hat einen vollständigen Flow wenn: +- Es direkt aus einem Fiat-Kauf stammt, ODER +- Es einen ParentLot hat, der seinerseits vollständig ist, ODER +- Es aus einem Swap mit vollständigen Quell-Lots stammt + +TOOLS +- `get_unlinked_lots`: Lots ohne vollständigen Flow +- `get_lot_details`: Details zu einem Lot inkl. Kette +- `get_trades_without_source_lot`: Trades die Lot-Zuordnung brauchen +- `link_lot_to_trade`: Verknüpft ein Lot mit einem Trade +- `create_lot_chain`: Erstellt Lot-Kette für Transfer + +OUTPUT +Antworte auf Deutsch. Erkläre steuerliche Auswirkungen bei Lot-Auswahl. +Bei Altbestand vs. Neubestand-Entscheidungen, weise auf die Konsequenzen hin. +"""; + + public LotLinkingAgentDefinition( + GetUnlinkedLotsTool getUnlinkedLotsTool, + GetLotDetailsTool getLotDetailsTool, + GetTradesWithoutSourceLotTool getTradesWithoutSourceLotTool, + LinkLotToTradeTool linkLotToTradeTool, + CreateLotChainTool createLotChainTool) + : base( + new AgentMetadata(KEY, "Lot-Verknüpfungs-Assistent", + "Vervollständigt Lot-Ketten für Steuerdokumentation"), + new AgentPromptDefinition(SystemPrompt), + [getUnlinkedLotsTool, getLotDetailsTool, getTradesWithoutSourceLotTool, + linkLotToTradeTool, createLotChainTool]) + { + } +} +``` + +### 8.2 Workflow: Lot-Verknüpfung bei Swap + +``` +Szenario: Benutzer hat 10 ETH auf Binance, tauscht 5 ETH gegen BTC + +Vorhandene Lots auf Binance: +- Lot #1: 3 ETH (Altbestand, Kauf 15.01.2020, 150€/ETH) +- Lot #2: 7 ETH (Neubestand, Kauf 15.03.2024, 3.000€/ETH) + +Trade: Sell 5 ETH → Buy 0.15 BTC (Swap) + +Agent fragt: +┌────────────────────────────────────────────────────────────────┐ +│ Für den Swap von 5 ETH → 0.15 BTC müssen Lots ausgewählt │ +│ werden. Welche Lots sollen verwendet werden? │ +│ │ +│ Option A: FIFO (Lot #1 zuerst) │ +│ - 3 ETH aus Lot #1 (Altbestand, AK: 450€) │ +│ - 2 ETH aus Lot #2 (Neubestand, AK: 6.000€) │ +│ → Gesamt-AK für neues BTC-Lot: 6.450€ │ +│ │ +│ Option B: Nur Neubestand (Lot #2) │ +│ - 5 ETH aus Lot #2 (Neubestand, AK: 15.000€) │ +│ → Gesamt-AK für neues BTC-Lot: 15.000€ │ +│ │ +│ Option C: Manuell auswählen │ +│ │ +│ Hinweis: Altbestand-ETH behalten = Bei späterem Fiat-Verkauf │ +│ steuerfrei! │ +└────────────────────────────────────────────────────────────────┘ + +Nach Benutzerentscheidung: +- Agent ruft `link_lot_to_trade` für jeden verwendeten Lot auf +- Neues BTC-Lot wird erstellt mit aggregierten Anschaffungskosten +- Flow-Validierung wird aktualisiert +``` + +--- + +## 9. Konfiguration + +### 9.1 `appsettings.json` + +```json +{ + "OpenAI": { + "Endpoint": "https://eastus2.api.cognitive.microsoft.com/", + "DeploymentName": "gpt-5.2", + "FastDeploymentName": "gpt-5-nano", + "EmbeddingDeploymentName": "text-embedding-3-large", + "EmbeddingVectorDimensions": 1536, + "ApiKey": "xxx" + } +} +``` + +### 9.2 OpenAI Settings Klasse + +```csharp +namespace CryptoTracker.Agent.Common; + +public class OpenAISettings +{ + public string Endpoint { get; set; } = string.Empty; + public string DeploymentName { get; set; } = "gpt-5.2"; + public string FastDeploymentName { get; set; } = "gpt-5-nano"; + public string EmbeddingDeploymentName { get; set; } = "text-embedding-3-large"; + public int EmbeddingVectorDimensions { get; set; } = 1536; + public string? ApiKey { get; set; } +} +``` + +### 9.3 Modell-Verwendung + +| Modell | Verwendung | Anwendungsfall | +|--------|------------|----------------| +| **gpt-5.2** | Hauptmodell | Agent-Konversationen, komplexe Entscheidungen, Steuerberatung | +| **gpt-5-nano** | Fast-Modell | Schnelle Vergleiche, Muster-Matching, einfache Klassifizierung | +| **text-embedding-3-large** | Embeddings | Ähnlichkeitssuche für Kommentare, Semantic Search | + +**Wann welches Modell:** +- `gpt-5.2`: Interaktive Agent-Konversationen, Lot-Auswahl mit Steuerberatung +- `gpt-5-nano`: Batch-Verarbeitung, schnelle Entscheidungen (z.B. "Ist das ein Staking Reward?") +- `text-embedding-3-large`: Suche nach ähnlichen Transaktionen/Kommentaren + +### 9.4 `Startup.cs` / DI-Registrierung + +```csharp +// OpenAI Settings +services.Configure(configuration.GetSection("OpenAI")); + +// Azure OpenAI Client (mit API Key oder DefaultAzureCredential) +services.AddSingleton(sp => +{ + var settings = sp.GetRequiredService>().Value; + if (!string.IsNullOrEmpty(settings.ApiKey)) + { + return new AzureOpenAIClient( + new Uri(settings.Endpoint), + new AzureKeyCredential(settings.ApiKey)); + } + return new AzureOpenAIClient( + new Uri(settings.Endpoint), + new DefaultAzureCredential()); +}); + +// Agent Builder +services.AddScoped(); + +// Agent Definitions (als Services registrieren) +services.AddScoped(); +services.AddScoped(); + +// Tools (Dependency Injection) +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); + +// Services +services.AddScoped(); +services.AddScoped(); +``` + +### 9.5 Azure Bicep Infrastruktur + +Siehe `infra/openai/openai.bicep` für Azure Deployment: + +```bicep +// Hauptmodell (gpt-5.2) +param deploymentName string = 'gpt-5.2' +param deploymentModelName string = 'gpt-5.2' +param deploymentModelVersion string = '2025-11-13' +param deploymentCapacity int = 50 + +// Fast-Modell (gpt-5-nano) +param fastDeploymentName string = 'gpt-5-nano' +param fastModelName string = 'gpt-5-nano' +param fastDeploymentModelVersion string = '2025-08-07' +param fastDeploymentCapacity int = 200 + +// Embeddings +param embeddingDeploymentName string = 'text-embedding-3-large' +param embeddingModelName string = 'text-embedding-3-large' +param embeddingModelVersion string = '1' +param embeddingDeploymentCapacity int = 350 +``` + +--- + +## 10. Implementierungs-Arbeitspakete + +### Phase 1: Grundlagen (Aufwand: ~16h) + +| AP | Beschreibung | Aufwand | +|----|--------------|---------| +| 1.1 | NuGet-Pakete hinzufügen, OpenAI-Konfiguration | 2h | +| 1.2 | Neue Entities: `TransactionLinkMetadata`, `AgentMemory` | 3h | +| 1.3 | Migration erstellen und anwenden | 1h | +| 1.4 | Agent-Interfaces und Base-Klassen | 4h | +| 1.5 | `AILinkingAgentBuilder` implementieren | 4h | +| 1.6 | DI-Registrierung | 2h | + +### Phase 2: Transaktions-Linking (Aufwand: ~20h) + +| AP | Beschreibung | Aufwand | +|----|--------------|---------| +| 2.1 | Tools: `GetUnlinkedTransactionsTool`, `GetTransactionDetailsTool` | 4h | +| 2.2 | Tools: `LinkTransactionsTool`, `MarkAsIntentionallyUnlinkedTool` | 4h | +| 2.3 | Tools: `SaveAgentMemoryTool`, `GetAgentMemoryTool` | 3h | +| 2.4 | `TransactionLinkingAgentDefinition` mit System-Prompt | 3h | +| 2.5 | `TransactionLinkingService` | 3h | +| 2.6 | `TransactionLinkingController` | 3h | + +### Phase 3: Lot-Linking (Aufwand: ~16h) + +| AP | Beschreibung | Aufwand | +|----|--------------|---------| +| 3.1 | Tools: `GetUnlinkedLotsTool`, `GetLotDetailsTool` | 4h | +| 3.2 | Tools: `GetTradesWithoutSourceLotTool`, `LinkLotToTradeTool` | 4h | +| 3.3 | Tool: `CreateLotChainTool` | 3h | +| 3.4 | `LotLinkingAgentDefinition` mit System-Prompt | 3h | +| 3.5 | `LotLinkingService` und Controller | 2h | + +### Phase 4: Blazor UI (Aufwand: ~20h) + +| AP | Beschreibung | Aufwand | +|----|--------------|---------| +| 4.1 | `TransaktionsPairing.razor` - Chat-Interface | 6h | +| 4.2 | `TransaktionsPairing.razor` - Vorschlags-Liste | 4h | +| 4.3 | `LotVerknuepfung.razor` - Wizard | 6h | +| 4.4 | Gemeinsame Komponenten (Lot-Auswahl-Dialog) | 4h | + +### Phase 5: Integration & Testing (Aufwand: ~12h) + +| AP | Beschreibung | Aufwand | +|----|--------------|---------| +| 5.1 | Integration nach Import (automatisch Linking starten) | 3h | +| 5.2 | Unit-Tests für Tools | 4h | +| 5.3 | Integration-Tests für Agent-Workflows | 3h | +| 5.4 | Dokumentation | 2h | + +**Gesamt: ~84h** + +--- + +## 11. Link-Reset-Funktionalität + +Da die alte `ProcessTransactionPairs()`-Methode bereits Verknüpfungen erstellt hat, die möglicherweise nicht korrekt sind, wird eine **Reset-Funktionalität** benötigt: + +### 11.1 Anforderungen + +- Alle bestehenden Transaktionsverknüpfungen zurücksetzen können +- Auch `TransactionLinkMetadata`-Einträge löschen +- Optional: Nur bestimmte Verknüpfungstypen zurücksetzen (z.B. nur `Automatic`, aber `Manual` behalten) +- `IsIntentionallyUnlinked`-Flags zurücksetzen (optional) + +### 11.2 Neues Tool: `ResetAllLinksTool` + +```csharp +public sealed class ResetAllLinksTool : IAgentTool +{ + public string GetToolName() => "reset_all_links"; + public string GetToolDescription() => """ + Setzt alle Transaktionsverknüpfungen zurück. + Parameter: + - keepManualLinks: true = Manuelle Verknüpfungen behalten + - resetIntentionallyUnlinked: true = Auch bewusst unverknüpfte zurücksetzen + ACHTUNG: Diese Aktion kann nicht rückgängig gemacht werden! + """; + + private async Task ResetAllLinksAsync( + bool keepManualLinks = true, + bool resetIntentionallyUnlinked = false) + { + // 1. Alle OppositeTransactionId/OppositeWalletId auf null setzen + // 2. TransactionLinkMetadata löschen (außer Manual wenn keepManualLinks) + // 3. Optional: IsIntentionallyUnlinked zurücksetzen + } +} +``` + +### 11.3 API-Endpoint + +```csharp +[HttpPost("reset")] +public async Task> ResetLinks( + [FromBody] ResetLinksRequest request, + CancellationToken ct) +{ + // Sicherheitsabfrage: Nur mit expliziter Bestätigung + if (!request.Confirmed) + return BadRequest("Bestätigung erforderlich"); + + var resetCount = await _linkingService.ResetAllLinksAsync( + request.KeepManualLinks, + request.ResetIntentionallyUnlinked, + ct); + + return Ok(new ResetResult { ResetCount = resetCount }); +} +``` + +### 11.4 UI: Reset-Dialog + +Im `TransaktionsPairing.razor`: +- Button "Alle Verknüpfungen zurücksetzen" +- Bestätigungsdialog mit Warnung +- Checkboxen für Optionen (manuelle behalten, etc.) + +--- + +## 12. Offene Punkte + +1. **Session-Management**: Wie wird der Konversationsverlauf gespeichert? + - Option A: In-Memory (geht bei Server-Restart verloren) + - Option B: In DB speichern (AgentSession-Entity) + - Option C: Redis/Cache + +2. **Streaming**: Soll Agent-Antwort gestreamt werden? + - Bessere UX bei langen Antworten + - Benötigt SignalR oder Server-Sent Events + +3. **Rate-Limiting**: Azure OpenAI hat TPM/RPM-Limits + - Retry-Logic implementieren + - Queue für Batch-Verarbeitung + +4. **Kosten-Optimierung**: + - **gpt-5.2** für komplexe Agent-Konversationen und Steuerberatung + - **gpt-5-nano** für schnelle Batch-Klassifizierung (z.B. "Ist das ein Staking Reward?") + - **Embeddings** für Ähnlichkeitssuche bei Kommentaren + - Caching von häufigen Mustern im AgentMemory + +5. **Fehlerbehandlung**: Was wenn Agent halluziniert? + - Validierung der Tool-Aufrufe + - Rollback bei fehlerhaften Verknüpfungen + +--- + +## 13. Nächste Schritte + +1. ⬜ NuGet-Pakete hinzufügen und Build verifizieren +2. ⬜ Entities und Migration erstellen +3. ⬜ Erstes Tool implementieren und testen +4. ⬜ Agent-Definition mit System-Prompt +5. ⬜ Einfache Chat-UI zum Testen +6. ⬜ Reset-Funktionalität implementieren +7. ⬜ Iterativ Tools und Prompts verbessern + +--- + +*Erstellt: 31.01.2026* +*Version: 1.1 - Reset-Funktionalität hinzugefügt* diff --git a/src/CryptoTracker.Client/Pages/ImportPages/ImportPageBase.razor b/src/CryptoTracker.Client/Pages/ImportPages/ImportPageBase.razor index 9db91a4..dd4017b 100644 --- a/src/CryptoTracker.Client/Pages/ImportPages/ImportPageBase.razor +++ b/src/CryptoTracker.Client/Pages/ImportPages/ImportPageBase.razor @@ -1,7 +1,7 @@ @using Microsoft.AspNetCore.Components.Forms @using CryptoTracker.Shared @using System.Text.Json -@typeparam TEntry +@typeparam TEntry where TEntry : notnull

@Title

diff --git a/src/CryptoTracker.Client/Pages/LotsLinking.razor b/src/CryptoTracker.Client/Pages/LotsLinking.razor new file mode 100644 index 0000000..9100393 --- /dev/null +++ b/src/CryptoTracker.Client/Pages/LotsLinking.razor @@ -0,0 +1,420 @@ +@page "/lots-linking" +@using CryptoTracker.Client.Common +@using CryptoTracker.Client.Shared +@using CryptoTracker.Shared + +Lot-Verknüpfung + + + + + +@* Statistik-Übersicht *@ +@if (FlowStatistics != null) +{ +
+
+
@FlowStatistics.TotalLots
+
Lots gesamt
+
+
+
@FlowStatistics.CompleteFlows
+
Vollständige Flows
+
+
+
@FlowStatistics.IncompleteFlows
+
Unvollständige Flows
+
+
+
@FlowStatistics.PendingAssignments
+
Ausstehende Zuordnungen
+
+
+} + +@* Tabs für verschiedene Ansichten *@ +
+ + + +
+ +@* Tab Content *@ +@if (!IsLoading) +{ + @if (ActiveTab == "pending") + { + @* Ausstehende Zuordnungen *@ + @if (PendingAssignments.Count == 0) + { +
+
+
Alle Transaktionen haben Lot-Zuordnungen!
+
+ } + else + { +
+

Ausstehende Lot-Zuordnungen

+

Diese Transaktionen/Trades benötigen eine manuelle Lot-Zuordnung.

+
+ + + + + + + + + + + + + + + + + + + + + + + } + } + else if (ActiveTab == "incomplete") + { + @* Unvollständige Flows *@ + @if (IncompleteLots.Count == 0) + { +
+
+
Alle Lot-Flows sind vollständig!
+
+ } + else + { +
+

Lots mit unvollständigem Flow

+

Diese Lots haben Lücken in ihrer Herkunfts-Dokumentation.

+
+ + + + + + + + + + + + + + + + + + + + + + + + } + } + else if (ActiveTab == "flow") + { + @* Flow-Visualisierung *@ +
+

Lot-Flow Visualisierung

+

Wähle ein Lot, um seinen vollständigen Flow zu sehen.

+
+ + + + @if (SelectedLotFlow != null) + { + + } + } +} + +@* Lot-Zuordnungs-Dialog *@ +@if (IsAssignmentDialogOpen && SelectedAssignment != null) +{ + +} + +@* Neues Lot erstellen Dialog *@ +@if (IsCreateLotDialogOpen && CreateLotForAssignment != null) +{ + +} + +@* Flow Details Dialog *@ +@if (IsFlowDetailsOpen && SelectedLotForFlow != null) +{ + +} + +@* Toast/Success Messages *@ +@if (SuccessMessage != null) +{ +
+ @SuccessMessage +
+} diff --git a/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs b/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs new file mode 100644 index 0000000..c071e45 --- /dev/null +++ b/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs @@ -0,0 +1,362 @@ +using CryptoTracker.Shared; +using Microsoft.AspNetCore.Components; + +namespace CryptoTracker.Client.Pages; + +public partial class LotsLinking +{ + [Inject] private ILotsApi LotsApi { get; set; } = default!; + [Inject] private ITransactionLinkingApi LinkingApi { get; set; } = default!; + + // State + private bool IsLoading = true; + private bool IsValidating = false; + private string ActiveTab = "pending"; + private string? SuccessMessage; + + // Data + private FlowStatisticsDTO? FlowStatistics; + private IList PendingAssignments = new List(); + private IList IncompleteLots = new List(); + + // Flow View + private int? SearchLotId; + private LotFlowValidationDTO? SelectedLotFlow; + + // Assignment Dialog + private bool IsAssignmentDialogOpen = false; + private PendingLotAssignmentDTO? SelectedAssignment; + private IList AvailableLots = new List(); + private List LotAllocations = new(); + private bool IsLoadingAvailableLots = false; + private bool IsAssigning = false; + private string? AssignmentError; + + private decimal AllocationDifference => + SelectedAssignment != null + ? SelectedAssignment.Quantity - LotAllocations.Sum(a => a.Quantity) + : 0; + + // Create Lot Dialog + private bool IsCreateLotDialogOpen = false; + private PendingLotAssignmentDTO? CreateLotForAssignment; + private string NewLotAcquisitionType = "ExternalDeposit"; + private DateTime NewLotAcquisitionDate = DateTime.Today; + private decimal NewLotAcquisitionPrice = 0; + private string? NewLotNote; + private bool IsCreatingLot = false; + private string? CreateLotError; + + // Flow Details Dialog + private bool IsFlowDetailsOpen = false; + private LotDTO? SelectedLotForFlow; + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + } + + private async Task LoadDataAsync() + { + IsLoading = true; + StateHasChanged(); + + try + { + var pendingTask = LotsApi.GetPendingLotAssignmentsAsync(); + + // Load pending assignments + PendingAssignments = await pendingTask; + + // Calculate statistics + FlowStatistics = new FlowStatisticsDTO + { + TotalLots = 0, // Will be calculated from actual data + CompleteFlows = 0, + IncompleteFlows = IncompleteLots.Count, + PendingAssignments = PendingAssignments.Count + }; + } + catch (Exception ex) + { + Console.WriteLine($"Error loading data: {ex.Message}"); + } + finally + { + IsLoading = false; + StateHasChanged(); + } + } + + private void SetActiveTab(string tab) + { + ActiveTab = tab; + } + + private async Task ValidateAllFlows() + { + IsValidating = true; + StateHasChanged(); + + try + { + // This would call an API to revalidate all flows + await Task.Delay(1000); // Placeholder + SuccessMessage = "Flow-Validierung abgeschlossen"; + await LoadDataAsync(); + } + finally + { + IsValidating = false; + StateHasChanged(); + _ = HideSuccessMessage(); + } + } + + // Assignment Dialog + private async Task OpenAssignmentDialog(PendingLotAssignmentDTO assignment) + { + SelectedAssignment = assignment; + IsAssignmentDialogOpen = true; + LotAllocations.Clear(); + AssignmentError = null; + AvailableLots = new List(); + + IsLoadingAvailableLots = true; + StateHasChanged(); + + try + { + // Load available lots for this symbol from the opposite wallet (for transfers) + // For external receives, load from any wallet + if (assignment.OppositeWalletName != null) + { + AvailableLots = await LotsApi.GetAvailableLotsAsync(assignment.OppositeWalletName, assignment.Symbol); + } + else + { + // No opposite wallet - this is an external receive, no lots to assign + AvailableLots = new List(); + } + } + catch (Exception ex) + { + AssignmentError = $"Fehler beim Laden der Lots: {ex.Message}"; + } + finally + { + IsLoadingAvailableLots = false; + StateHasChanged(); + } + } + + private void CloseAssignmentDialog() + { + IsAssignmentDialogOpen = false; + SelectedAssignment = null; + } + + private void SetAllocation(int lotId, ChangeEventArgs e) + { + var value = decimal.TryParse(e.Value?.ToString(), out var qty) ? qty : 0; + + var existing = LotAllocations.FirstOrDefault(a => a.LotId == lotId); + if (existing != null) + { + LotAllocations.Remove(existing); + } + + if (value > 0) + { + LotAllocations.Add(new LotAllocationDTO(lotId, value)); + } + } + + private async Task ConfirmAssignment() + { + if (SelectedAssignment == null || LotAllocations.Count == 0) + return; + + IsAssigning = true; + AssignmentError = null; + StateHasChanged(); + + try + { + // This would call the appropriate API based on type + if (SelectedAssignment.Type == "Transaction") + { + // For transactions, we need the send/receive pair + // This is a simplified version - real implementation needs more context + await Task.Delay(500); // Placeholder + } + else if (SelectedAssignment.Type == "Trade") + { + // For trades (sells), use SellLotsAsync + // await LotsApi.SellLotsAsync(new SellLotsRequest(...)); + await Task.Delay(500); // Placeholder + } + + SuccessMessage = "Lot-Zuordnung erfolgreich!"; + CloseAssignmentDialog(); + await LoadDataAsync(); + } + catch (Exception ex) + { + AssignmentError = $"Fehler: {ex.Message}"; + } + finally + { + IsAssigning = false; + StateHasChanged(); + _ = HideSuccessMessage(); + } + } + + // Create Lot Dialog + private void OpenCreateLotDialog(PendingLotAssignmentDTO assignment) + { + CreateLotForAssignment = assignment; + IsCreateLotDialogOpen = true; + NewLotAcquisitionType = "ExternalDeposit"; + NewLotAcquisitionDate = assignment.DateTime.Date; + NewLotAcquisitionPrice = 0; + NewLotNote = null; + CreateLotError = null; + + // Close assignment dialog if open + if (IsAssignmentDialogOpen) + { + IsAssignmentDialogOpen = false; + } + } + + private void CloseCreateLotDialog() + { + IsCreateLotDialogOpen = false; + CreateLotForAssignment = null; + } + + private async Task CreateLot() + { + if (CreateLotForAssignment == null) + return; + + IsCreatingLot = true; + CreateLotError = null; + StateHasChanged(); + + try + { + // Look up wallet ID from name (simplified - real implementation needs API) + var request = new CreateManualLotRequest( + Symbol: CreateLotForAssignment.Symbol, + WalletId: 0, // Would need to resolve from wallet name + Quantity: CreateLotForAssignment.Quantity, + AcquisitionDate: new DateTimeOffset(NewLotAcquisitionDate, TimeSpan.Zero), + AcquisitionPriceEur: NewLotAcquisitionPrice, + AcquisitionType: NewLotAcquisitionType, + SourceTransactionId: CreateLotForAssignment.Type == "Transaction" ? CreateLotForAssignment.Id : null, + Note: NewLotNote + ); + + await LotsApi.CreateManualLotAsync(request); + + SuccessMessage = "Lot erfolgreich erstellt!"; + CloseCreateLotDialog(); + await LoadDataAsync(); + } + catch (Exception ex) + { + CreateLotError = $"Fehler: {ex.Message}"; + } + finally + { + IsCreatingLot = false; + StateHasChanged(); + _ = HideSuccessMessage(); + } + } + + // Flow Details + private async Task OpenFlowDetails(LotDTO lot) + { + SelectedLotForFlow = lot; + IsFlowDetailsOpen = true; + SelectedLotFlow = null; + StateHasChanged(); + + try + { + // Load flow for this lot + // SelectedLotFlow = await LotsApi.GetLotFlowAsync(lot.Id); + await Task.Delay(500); // Placeholder + + // Create mock data for now + SelectedLotFlow = new LotFlowValidationDTO( + LotId: lot.Id, + Symbol: lot.Symbol, + Quantity: lot.OriginalQuantity, + AcquisitionDate: lot.AcquisitionDate, + IsComplete: lot.IsFlowComplete, + IncompleteReason: lot.FlowIncompleteReason, + FlowChain: new List + { + new LotFlowStepDTO(lot.Id, lot.Symbol, lot.OriginalQuantity, lot.AcquisitionType, lot.SourceTradeId, lot.SourceTransactionId, lot.AcquisitionDate) + }, + EffectiveQuantity: lot.RemainingQuantity + ); + } + catch (Exception ex) + { + Console.WriteLine($"Error loading flow: {ex.Message}"); + } + + StateHasChanged(); + } + + private void CloseFlowDetails() + { + IsFlowDetailsOpen = false; + SelectedLotForFlow = null; + SelectedLotFlow = null; + } + + private async Task LoadLotFlow() + { + if (SearchLotId == null) + return; + + try + { + var lot = await LotsApi.GetLotByIdAsync(SearchLotId.Value); + if (lot != null) + { + await OpenFlowDetails(lot); + IsFlowDetailsOpen = false; // Show inline instead + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading lot: {ex.Message}"); + } + } + + private async Task HideSuccessMessage() + { + await Task.Delay(5000); + SuccessMessage = null; + await InvokeAsync(StateHasChanged); + } +} + +/// +/// Statistics for flow completeness +/// +public record FlowStatisticsDTO +{ + public int TotalLots { get; init; } + public int CompleteFlows { get; init; } + public int IncompleteFlows { get; init; } + public int PendingAssignments { get; init; } +} diff --git a/src/CryptoTracker.Client/Pages/TransactionLinking.razor b/src/CryptoTracker.Client/Pages/TransactionLinking.razor new file mode 100644 index 0000000..842e191 --- /dev/null +++ b/src/CryptoTracker.Client/Pages/TransactionLinking.razor @@ -0,0 +1,416 @@ +@page "/linking" +@using CryptoTracker.Client.Common +@using CryptoTracker.Client.Shared +@using CryptoTracker.Shared +@inject ITransactionLinkingApi LinkingApi + +Transaktions-Verknüpfung + +@* Wizard anzeigen wenn aktiv *@ +@if (IsWizardOpen) +{ + +} + + + + + +@* Statistik-Karten *@ +@if (Statistics != null) +{ +
+
+
@Statistics.TotalTransactions
+
Gesamt
+
+
+
@Statistics.LinkedTransactions
+
Verknüpft (@Statistics.LinkedPercent%)
+
+
+
@Statistics.UnlinkedTransactions
+
Offen
+
+
+
@Statistics.IntentionallyUnlinked
+
Externe
+
+ @if (Statistics.UnconfirmedLinks > 0) + { +
+
@Statistics.UnconfirmedLinks
+
Unbestätigt
+
+ } +
+ + @* Unverknüpfte nach Symbol *@ + @if (Statistics.UnlinkedBySymbol.Count > 0) + { +
+ Offen nach Coin: + @foreach (var (symbol, count) in Statistics.UnlinkedBySymbol.OrderByDescending(x => x.Value).Take(8)) + { + + } + @if (!string.IsNullOrEmpty(SelectedSymbol)) + { + + } +
+ } +} + +@* AI Chat Panel (aufklappbar) *@ +@if (IsChatOpen && IsAIConfigured) +{ +
+
+

AI-Assistent

+ +
+
+ @foreach (var msg in ChatMessages) + { +
+ @if (msg.IsLoading) + { +
+ + + +
+ } + else + { +
@((MarkupString)FormatMarkdown(msg.Content))
+
@msg.Timestamp.ToLocalTime().ToString("HH:mm")
+ } +
+ } +
+
+ + +
+
+} + +@* Typ-Filter Tabs *@ +
+ + + +
+ +@* Unverknüpfte Transaktionen Grid *@ +@if (!IsLoading) +{ + @if (UnlinkedTransactions.Count == 0) + { +
+
+
Alle Transaktionen sind verknüpft!
+
+ } + else + { + + + + + + + + + + + + + + + + + + + + + + + + + + + + } +} + +@* Manuelles Verknüpfen Dialog *@ +@if (IsLinkDialogOpen && LinkDialogTransaction != null) +{ + +} + +@* Als extern markieren Dialog *@ +@if (IsMarkExternalDialogOpen && MarkExternalTransaction != null) +{ + +} + +@* Reset Dialog *@ +@if (IsResetDialogOpen) +{ + +} + +@* Toast/Success Messages *@ +@if (SuccessMessage != null) +{ +
+ @SuccessMessage +
+} diff --git a/src/CryptoTracker.Client/Pages/TransactionLinking.razor.cs b/src/CryptoTracker.Client/Pages/TransactionLinking.razor.cs new file mode 100644 index 0000000..c332f3a --- /dev/null +++ b/src/CryptoTracker.Client/Pages/TransactionLinking.razor.cs @@ -0,0 +1,455 @@ +using CryptoTracker.Shared; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +namespace CryptoTracker.Client.Pages; + +public partial class TransactionLinking +{ + // State + private bool IsLoading = true; + private bool IsAIConfigured = false; + private LinkingStatisticsDTO? Statistics; + private IList UnlinkedTransactions = new List(); + private IList SelectedTransactions = new List(); + private string? SelectedSymbol; + private string? SelectedType; + private string? SuccessMessage; + + // Wizard State + private bool IsWizardOpen = false; + + // Chat State + private bool IsChatOpen = false; + private bool IsChatLoading = false; + private string ChatInput = ""; + private List ChatMessages = new(); + private ElementReference ChatMessagesRef; + + // Link Dialog State + private bool IsLinkDialogOpen = false; + private UnlinkedTransactionDTO? LinkDialogTransaction; + private IList PotentialMatches = new List(); + private UnlinkedTransactionDTO? SelectedMatch; + private string? LinkReason; + private bool IsLoadingMatches = false; + private bool IsLinking = false; + private string? LinkError; + + // Mark External Dialog State + private bool IsMarkExternalDialogOpen = false; + private UnlinkedTransactionDTO? MarkExternalTransaction; + private string ExternalReason = ""; + private bool IsMarkingExternal = false; + private string? MarkExternalError; + + // Reset Dialog State + private bool IsResetDialogOpen = false; + private bool ResetKeepManual = true; + private bool ResetIncludeExternal = false; + private bool IsResetting = false; + + // Auto-Link State + private bool IsAutoLinking = false; + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + } + + // Wizard + private void OpenWizard() + { + IsWizardOpen = true; + } + + private async Task CloseWizard() + { + IsWizardOpen = false; + await LoadDataAsync(); + } + + private async Task LoadDataAsync() + { + IsLoading = true; + StateHasChanged(); + + try + { + // Sequential calls to avoid DbContext threading issues + var status = await LinkingApi.GetStatusAsync(); + IsAIConfigured = status.IsConfigured; + + Statistics = await LinkingApi.GetStatisticsAsync(); + UnlinkedTransactions = await LinkingApi.GetUnlinkedAsync(SelectedType, SelectedSymbol); + } + catch (Exception ex) + { + Console.WriteLine($"Error loading data: {ex.Message}"); + } + finally + { + IsLoading = false; + StateHasChanged(); + } + } + + private async Task FilterBySymbol(string symbol) + { + SelectedSymbol = symbol; + await LoadDataAsync(); + } + + private async Task ClearSymbolFilter() + { + SelectedSymbol = null; + await LoadDataAsync(); + } + + private async Task FilterByType(string? type) + { + SelectedType = type; + await LoadDataAsync(); + } + + // Selection + private void SelectAll(bool selected) + { + if (selected) + SelectedTransactions = UnlinkedTransactions.ToList(); + else + SelectedTransactions = new List(); + } + + private void ToggleSelection(UnlinkedTransactionDTO tx, bool selected) + { + var list = SelectedTransactions.ToList(); + if (selected && !list.Contains(tx)) + list.Add(tx); + else if (!selected) + list.Remove(tx); + SelectedTransactions = list; + } + + // Chat + private void ToggleChat() + { + IsChatOpen = !IsChatOpen; + if (IsChatOpen && ChatMessages.Count == 0) + { + _ = StartChatSession(); + } + } + + private async Task StartChatSession() + { + IsChatLoading = true; + ChatMessages.Add(new ChatMessageDTO { Role = "assistant", Content = "", IsLoading = true }); + StateHasChanged(); + + try + { + var result = await LinkingApi.StartSessionAsync(); + ChatMessages.RemoveAt(ChatMessages.Count - 1); + ChatMessages.Add(new ChatMessageDTO + { + Role = "assistant", + Content = result.AgentResponse, + Timestamp = DateTimeOffset.Now + }); + } + catch (Exception ex) + { + ChatMessages.RemoveAt(ChatMessages.Count - 1); + ChatMessages.Add(new ChatMessageDTO + { + Role = "assistant", + Content = $"Fehler: {ex.Message}", + Timestamp = DateTimeOffset.Now + }); + } + finally + { + IsChatLoading = false; + StateHasChanged(); + } + } + + private async Task OnChatKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(ChatInput)) + { + await SendChatMessage(); + } + } + + private async Task SendChatMessage() + { + if (string.IsNullOrWhiteSpace(ChatInput) || IsChatLoading) + return; + + var userMessage = ChatInput; + ChatInput = ""; + + ChatMessages.Add(new ChatMessageDTO + { + Role = "user", + Content = userMessage, + Timestamp = DateTimeOffset.Now + }); + + IsChatLoading = true; + ChatMessages.Add(new ChatMessageDTO { Role = "assistant", Content = "", IsLoading = true }); + StateHasChanged(); + + try + { + var result = await LinkingApi.SendMessageAsync(userMessage); + ChatMessages.RemoveAt(ChatMessages.Count - 1); + ChatMessages.Add(new ChatMessageDTO + { + Role = "assistant", + Content = result.AgentResponse, + Timestamp = DateTimeOffset.Now + }); + + // Refresh data after agent interaction + await LoadDataAsync(); + } + catch (Exception ex) + { + ChatMessages.RemoveAt(ChatMessages.Count - 1); + ChatMessages.Add(new ChatMessageDTO + { + Role = "assistant", + Content = $"Fehler: {ex.Message}", + Timestamp = DateTimeOffset.Now + }); + } + finally + { + IsChatLoading = false; + StateHasChanged(); + } + } + + // Auto-Link + private async Task RunAutoLink() + { + IsAutoLinking = true; + StateHasChanged(); + + try + { + var result = await LinkingApi.RunAutoLinkAsync(); + if (result.Success) + { + SuccessMessage = $"Auto-Verknüpfung abgeschlossen: {result.LinkedCount} verknüpft, {result.MarkedUnlinkedCount} als extern markiert, {result.RemainingUnlinkedCount} offen."; + await LoadDataAsync(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Auto-link error: {ex.Message}"); + } + finally + { + IsAutoLinking = false; + StateHasChanged(); + + // Auto-hide success message + _ = Task.Delay(5000).ContinueWith(_ => + { + SuccessMessage = null; + InvokeAsync(StateHasChanged); + }); + } + } + + // Link Dialog + private async Task OpenLinkDialog(UnlinkedTransactionDTO tx) + { + LinkDialogTransaction = tx; + IsLinkDialogOpen = true; + SelectedMatch = null; + LinkReason = null; + LinkError = null; + PotentialMatches = new List(); + + IsLoadingMatches = true; + StateHasChanged(); + + try + { + // Load opposite type transactions for the same symbol + var oppositeType = tx.IsSend ? "receive" : "send"; + PotentialMatches = await LinkingApi.GetUnlinkedAsync(oppositeType, tx.Symbol, 50, 0); + } + catch (Exception ex) + { + Console.WriteLine($"Error loading matches: {ex.Message}"); + } + finally + { + IsLoadingMatches = false; + StateHasChanged(); + } + } + + private void CloseLinkDialog() + { + IsLinkDialogOpen = false; + LinkDialogTransaction = null; + } + + private void SelectMatch(UnlinkedTransactionDTO match) + { + SelectedMatch = match; + } + + private async Task ConfirmLink() + { + if (LinkDialogTransaction == null || SelectedMatch == null) + return; + + IsLinking = true; + LinkError = null; + StateHasChanged(); + + try + { + var sendId = LinkDialogTransaction.IsSend ? LinkDialogTransaction.Id : SelectedMatch.Id; + var receiveId = LinkDialogTransaction.IsReceive ? LinkDialogTransaction.Id : SelectedMatch.Id; + + var result = await LinkingApi.ManualLinkAsync(sendId, receiveId, LinkReason); + + if (result.Success) + { + SuccessMessage = "Transaktionen erfolgreich verknüpft!"; + CloseLinkDialog(); + await LoadDataAsync(); + } + else + { + LinkError = result.Message; + } + } + catch (Exception ex) + { + LinkError = $"Fehler: {ex.Message}"; + } + finally + { + IsLinking = false; + StateHasChanged(); + } + } + + // Mark External Dialog + private void OpenMarkExternalDialog(UnlinkedTransactionDTO tx) + { + MarkExternalTransaction = tx; + ExternalReason = ""; + MarkExternalError = null; + IsMarkExternalDialogOpen = true; + } + + private void CloseMarkExternalDialog() + { + IsMarkExternalDialogOpen = false; + MarkExternalTransaction = null; + } + + private async Task ConfirmMarkExternal() + { + if (MarkExternalTransaction == null || string.IsNullOrWhiteSpace(ExternalReason)) + return; + + IsMarkingExternal = true; + MarkExternalError = null; + StateHasChanged(); + + try + { + var result = await LinkingApi.MarkAsIntentionallyUnlinkedAsync(MarkExternalTransaction.Id, ExternalReason); + + if (result.Success) + { + SuccessMessage = "Transaktion als extern markiert!"; + CloseMarkExternalDialog(); + await LoadDataAsync(); + } + else + { + MarkExternalError = result.Message; + } + } + catch (Exception ex) + { + MarkExternalError = $"Fehler: {ex.Message}"; + } + finally + { + IsMarkingExternal = false; + StateHasChanged(); + } + } + + // Reset Dialog + private void OpenResetDialog() + { + ResetKeepManual = true; + ResetIncludeExternal = false; + IsResetDialogOpen = true; + } + + private void CloseResetDialog() + { + IsResetDialogOpen = false; + } + + private async Task ConfirmReset() + { + IsResetting = true; + StateHasChanged(); + + try + { + var result = await LinkingApi.ResetLinksAsync(ResetKeepManual, ResetIncludeExternal); + SuccessMessage = $"Reset abgeschlossen: {result.ResetCount} Verknüpfungen zurückgesetzt."; + CloseResetDialog(); + await LoadDataAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"Reset error: {ex.Message}"); + } + finally + { + IsResetting = false; + StateHasChanged(); + } + } + + // Helpers + private static string TruncateAddress(string address) + { + if (string.IsNullOrEmpty(address) || address.Length <= 16) + return address; + return $"{address[..8]}...{address[^6..]}"; + } + + private static string FormatMarkdown(string text) + { + if (string.IsNullOrEmpty(text)) + return ""; + + // Simple markdown formatting + text = System.Text.RegularExpressions.Regex.Replace(text, @"\*\*(.+?)\*\*", "$1"); + text = System.Text.RegularExpressions.Regex.Replace(text, @"\*(.+?)\*", "$1"); + text = System.Text.RegularExpressions.Regex.Replace(text, @"`(.+?)`", "$1"); + text = text.Replace("\n", "
"); + + return text; + } +} diff --git a/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs b/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs index 7c265bf..27753e8 100644 --- a/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs +++ b/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs @@ -216,6 +216,111 @@ private void CloseDetails() DetailsError = null; } + private bool CanToggleHidden => CurrentHidden.HasValue && SelectedFlowType.HasValue && SelectedFlowId.HasValue; + + private bool IsCurrentHidden => CurrentHidden ?? false; + + private bool? CurrentHidden => Details?.FlowType switch + { + FlowType.Trade => Details?.Trade?.IsHidden, + FlowType.Transaction => Details?.Transaction?.IsHidden, + _ => null + }; + + private async Task ToggleHiddenAsync() + { + if (!SelectedFlowType.HasValue || !SelectedFlowId.HasValue || CurrentHidden == null) + return; + + IsToggleHiddenBusy = true; + DetailsError = null; + + try + { + var newHiddenState = !CurrentHidden.Value; + var success = await TransactionsApi.SetHiddenAsync(new SetHiddenRequest(SelectedFlowType.Value, SelectedFlowId.Value, newHiddenState)); + if (!success) + { + DetailsError = "Eintrag konnte nicht aktualisiert werden."; + } + else + { + UpdateCurrentHiddenState(newHiddenState); + await LoadTransactionsAsync(); + } + } + catch (Exception ex) + { + DetailsError = ex.Message; + } + IsToggleHiddenBusy = false; + } + + private void UpdateCurrentHiddenState(bool isHidden) + { + if (Details == null) + return; + + if (Details.FlowType == FlowType.Trade && Details.Trade != null) + { + Details = Details with { Trade = Details.Trade with { IsHidden = isHidden } }; + } + else if (Details.FlowType == FlowType.Transaction && Details.Transaction != null) + { + Details = Details with { Transaction = Details.Transaction with { IsHidden = isHidden } }; + } + } + + private void OnRowRender(RowRenderEventArgs args) + { + if (args.Data == null || !args.Data.IsHidden) + return; + + var attributes = args.Attributes; + if (attributes == null) + return; + + if (attributes.TryGetValue("class", out var existing)) + { + attributes["class"] = $"{existing} hidden-row"; + } + else + { + attributes["class"] = "hidden-row"; + } + } + + private static bool ParseBool(object? value, bool fallback) + { + if (value is bool boolValue) + return boolValue; + + if (value is string stringValue) + { + return ParseBool(stringValue, fallback); + } + + return fallback; + } + + private static bool ParseBool(string? value, bool fallback) + { + if (string.IsNullOrWhiteSpace(value)) + return fallback; + + if (bool.TryParse(value, out var parsed)) + return parsed; + + return value.Trim() switch + { + "1" => true, + "0" => false, + "on" => true, + "off" => false, + _ => fallback + }; + } + /// /// Gets the wallet name for lot assignment (source wallet for outflows) /// @@ -228,6 +333,38 @@ private string GetLotAssignmentWallet() return LotAssignmentRow.SourceWallet ?? string.Empty; } + /// + /// Determines if the row represents a sell trade (crypto sold for fiat/other crypto) + /// vs. a transfer (crypto moved between own wallets) + /// + private static bool IsSellTrade(TransactionRowDTO row) + { + // A sell trade is a Trade flow type where crypto is sold + // A transfer is a Transaction flow type (Send/Receive between wallets) + return row.FlowType == FlowType.Trade; + } + + /// + /// Determines if a transaction requires lot assignment + /// + private static bool RequiresLotAssignment(TransactionRowDTO row) + { + // Sell trades need lot assignment to calculate gains + if (row.FlowType == FlowType.Trade) + { + // Only outgoing trades (sells) need lot assignment + return row.FlowDirection == FlowDirection.Outflow; + } + + // Transfers (Send transactions) need lot assignment to track which lots are moved + if (row.FlowType == FlowType.Transaction) + { + return row.FlowDirection == FlowDirection.Outflow; + } + + return false; + } + /// /// Opens the lot assignment modal for a given transaction row /// diff --git a/src/CryptoTracker.Client/RestClients/DataImportRestClient.cs b/src/CryptoTracker.Client/RestClients/DataImportRestClient.cs index b65f14c..202db67 100644 --- a/src/CryptoTracker.Client/RestClients/DataImportRestClient.cs +++ b/src/CryptoTracker.Client/RestClients/DataImportRestClient.cs @@ -48,10 +48,11 @@ public async Task ImportAutoAsync(string walletName, IBrowserFile file, ImportDo response.EnsureSuccessStatusCode(); } - public async Task ProcessTransactionPairsAsync() - { - using var content = new MultipartFormDataContent(); - var response = await _http.PostAsync("api/DataImport/ProcessTransactionPairs", content); - response.EnsureSuccessStatusCode(); - } + //Obsolete durch neue Linking Logic + //public async Task ProcessTransactionPairsAsync() + //{ + // using var content = new MultipartFormDataContent(); + // var response = await _http.PostAsync("api/DataImport/ProcessTransactionPairs", content); + // response.EnsureSuccessStatusCode(); + //} } diff --git a/src/CryptoTracker.Client/Shared/Api/IDataImportApi.cs b/src/CryptoTracker.Client/Shared/Api/IDataImportApi.cs index fc87c4d..a93081b 100644 --- a/src/CryptoTracker.Client/Shared/Api/IDataImportApi.cs +++ b/src/CryptoTracker.Client/Shared/Api/IDataImportApi.cs @@ -7,5 +7,6 @@ public interface IDataImportApi Task ImportFileAsync(ImportDocumentType type, string walletName, IBrowserFile file); Task PreviewImportAsync(IBrowserFile file); Task ImportAutoAsync(string walletName, IBrowserFile file, ImportDocumentType? documentType = null); - Task ProcessTransactionPairsAsync(); + //Obsolete durch neue Linking Logic + //Task ProcessTransactionPairsAsync(); } diff --git a/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs b/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs new file mode 100644 index 0000000..719f8d2 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs @@ -0,0 +1,57 @@ +namespace CryptoTracker.Shared; + +/// +/// API für KI-gestützte Transaktionsverknüpfung +/// +public interface ITransactionLinkingApi +{ + /// + /// Prüft ob der AI-Service konfiguriert ist + /// + Task GetStatusAsync(); + + /// + /// Gibt Statistiken über Verknüpfungen zurück + /// + Task GetStatisticsAsync(); + + /// + /// Startet eine neue Agent-Session + /// + Task StartSessionAsync(); + + /// + /// Sendet eine Nachricht an den Agent + /// + Task SendMessageAsync(string message); + + /// + /// Führt automatische Verknüpfung durch + /// + Task RunAutoLinkAsync(); + + /// + /// Gibt unverknüpfte Transaktionen zurück + /// + Task> GetUnlinkedAsync(string? type = null, string? symbol = null, int limit = 100, int offset = 0); + + /// + /// Verknüpft zwei Transaktionen manuell + /// + Task ManualLinkAsync(int sendId, int receiveId, string? reason = null); + + /// + /// Bestätigt vorgeschlagene Verknüpfungen + /// + Task ConfirmLinksAsync(IList transactionIds); + + /// + /// Setzt alle Verknüpfungen zurück + /// + Task ResetLinksAsync(bool keepManualLinks = true, bool resetIntentionallyUnlinked = false); + + /// + /// Markiert eine Transaktion als absichtlich unverknüpft (externe Einnahme) + /// + Task MarkAsIntentionallyUnlinkedAsync(int transactionId, string reason); +} diff --git a/src/CryptoTracker.Client/Shared/LinkingWizard.razor b/src/CryptoTracker.Client/Shared/LinkingWizard.razor new file mode 100644 index 0000000..a2e3a43 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LinkingWizard.razor @@ -0,0 +1,278 @@ +@using CryptoTracker.Shared + +
+
+
+

Transaktions-Verknüpfung

+

Schritt @CurrentStep von @TotalSteps

+ +
+ +
+ @for (int i = 1; i <= TotalSteps; i++) + { + var step = i; +
+
+ @if (step < CurrentStep) + { + + } + else + { + @step + } +
+
@GetStepLabel(step)
+
+ @if (step < TotalSteps) + { +
+ } + } +
+ +
+ @if (CurrentStep == 1) + { + @* Step 1: Welcome *@ +
+
🔗
+

Willkommen zur Transaktions-Verknüpfung

+

+ Dieser Assistent hilft dir, Send- und Receive-Transaktionen zwischen deinen Wallets zu verknüpfen. + Das ist wichtig für die korrekte Steuerberechnung. +

+ +
+
+
🔬
+

Warum verknüpfen?

+

Transfers zwischen eigenen Wallets sind keine steuerpflichtigen Ereignisse. + Die Verknüpfung stellt sicher, dass Lot-Informationen (Kaufdatum, Kaufpreis) + korrekt weitergegeben werden.

+
+
+
🤖
+

KI-Unterstützung

+

Unser KI-Assistent analysiert deine Transaktionen und schlägt automatisch + passende Verknüpfungen vor. Du behältst die volle Kontrolle.

+
+
+ + @if (Statistics != null) + { +
+

Aktuelle Situation

+
+
+ @Statistics.UnlinkedTransactions + Unverknüpfte Transaktionen +
+
+ @Statistics.LinkedTransactions + Bereits verknüpft +
+
+ @Statistics.IntentionallyUnlinked + Als extern markiert +
+
+
+ } +
+ } + else if (CurrentStep == 2) + { + @* Step 2: Auto-Link *@ +
+
+

Automatische Verknüpfung

+

+ Klicke auf "Auto-Verknüpfung starten", um die KI-gestützte Analyse zu starten. + Der Algorithmus sucht nach passenden Send/Receive-Paaren basierend auf: +

+
    +
  • Gleiches Asset (z.B. BTC zu BTC)
  • +
  • Ähnliche Menge (unter Berücksichtigung von Gebühren)
  • +
  • Zeitliche Nähe (typischerweise innerhalb von Minuten bis Stunden)
  • +
  • Bekannte Wallet-Adressen
  • +
+ + @if (!IsAutoLinking && AutoLinkResult == null) + { +
+ +
+ } + else if (IsAutoLinking) + { +
+
+

Analysiere Transaktionen...

+
+ } + else if (AutoLinkResult != null) + { +
+
+ +

Analyse abgeschlossen!

+
+
+
+ @AutoLinkResult.LinkedCount + Automatisch verknüpft +
+
+ @AutoLinkResult.MarkedUnlinkedCount + Als extern markiert +
+
+ @AutoLinkResult.RemainingUnlinkedCount + Noch offen +
+
+ @if (!string.IsNullOrEmpty(AutoLinkResult.Summary)) + { +
+
Zusammenfassung:
+

@AutoLinkResult.Summary

+
+ } +
+ } +
+ } + else if (CurrentStep == 3) + { + @* Step 3: Manual Review *@ +
+
🔎
+

Manuelle Überprüfung

+ + @if (RemainingUnlinked.Count == 0) + { +
+
+

Alle Transaktionen verknüpft!

+

Es gibt keine weiteren unverknüpften Transaktionen.

+
+ } + else + { +

+ Diese @RemainingUnlinked.Count Transaktionen konnten nicht automatisch zugeordnet werden. + Bitte überprüfe sie manuell. +

+ +
+ @foreach (var tx in RemainingUnlinked.Take(10)) + { +
+
+ + @(tx.IsSend ? "Send" : "Receive") + + @tx.Symbol + @tx.Quantity.ToString("N8") + @tx.WalletName + @tx.DateTime.ToString("dd.MM.yyyy HH:mm") +
+
+ + +
+
+ } +
+ + @if (RemainingUnlinked.Count > 10) + { +

+ Und @(RemainingUnlinked.Count - 10) weitere... + Du kannst nach dem Wizard auf der Hauptseite alle bearbeiten. +

+ } + } +
+ } + else if (CurrentStep == 4) + { + @* Step 4: Summary *@ +
+
+

Verknüpfung abgeschlossen!

+ + @if (FinalStatistics != null) + { +
+
+ @FinalStatistics.LinkedTransactions + Verknüpfte Transaktionen + @FinalStatistics.LinkedPercent% +
+
+ @FinalStatistics.IntentionallyUnlinked + Externe Transaktionen +
+ @if (FinalStatistics.UnlinkedTransactions > 0) + { +
+ @FinalStatistics.UnlinkedTransactions + Noch offen +
+ } +
+ +
+

Nächste Schritte

+
    + @if (FinalStatistics.UnlinkedTransactions > 0) + { +
  • Bearbeite die verbleibenden @FinalStatistics.UnlinkedTransactions offenen Transaktionen auf der Hauptseite
  • + } +
  • Überprüfe die Lot-Zuordnungen unter "Steuerlots"
  • +
  • Generiere deinen Steuerbericht unter "Bilanzen"
  • +
+
+ } +
+ } +
+ + +
+
diff --git a/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs b/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs new file mode 100644 index 0000000..be0329b --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs @@ -0,0 +1,142 @@ +using CryptoTracker.Shared; +using Microsoft.AspNetCore.Components; + +namespace CryptoTracker.Client.Shared; + +public partial class LinkingWizard +{ + [Parameter] public EventCallback OnComplete { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public LinkingStatisticsDTO? Statistics { get; set; } + + [Inject] private ITransactionLinkingApi LinkingApi { get; set; } = default!; + + private int CurrentStep = 1; + private const int TotalSteps = 4; + private bool IsProcessing = false; + private bool IsAutoLinking = false; + private AutoLinkResultDTO? AutoLinkResult; + private IList RemainingUnlinked = new List(); + private LinkingStatisticsDTO? FinalStatistics; + + private bool CanProceed => CurrentStep switch + { + 1 => true, + 2 => AutoLinkResult != null, + 3 => true, + 4 => true, + _ => false + }; + + private string GetStepLabel(int step) => step switch + { + 1 => "Übersicht", + 2 => "Auto-Link", + 3 => "Prüfen", + 4 => "Fertig", + _ => "" + }; + + private async Task NextStep() + { + if (CurrentStep == 2 && AutoLinkResult != null) + { + // Load remaining unlinked for step 3 + IsProcessing = true; + try + { + RemainingUnlinked = await LinkingApi.GetUnlinkedAsync(); + } + finally + { + IsProcessing = false; + } + } + + if (CurrentStep == 3) + { + // Load final statistics for step 4 + IsProcessing = true; + try + { + FinalStatistics = await LinkingApi.GetStatisticsAsync(); + } + finally + { + IsProcessing = false; + } + } + + if (CurrentStep < TotalSteps) + { + CurrentStep++; + } + } + + private void PreviousStep() + { + if (CurrentStep > 1) + { + CurrentStep--; + } + } + + private async Task RunAutoLink() + { + IsAutoLinking = true; + StateHasChanged(); + + try + { + AutoLinkResult = await LinkingApi.RunAutoLinkAsync(); + } + catch (Exception ex) + { + AutoLinkResult = new AutoLinkResultDTO + { + Success = false, + Summary = $"Fehler: {ex.Message}" + }; + } + finally + { + IsAutoLinking = false; + StateHasChanged(); + } + } + + private async Task OnLinkTransaction(UnlinkedTransactionDTO tx) + { + // This would open a sub-dialog for linking + // For now, we'll just refresh the list + // The actual linking happens on the main page + await Task.CompletedTask; + } + + private async Task OnMarkExternal(UnlinkedTransactionDTO tx) + { + try + { + var reason = tx.IsReceive ? "Externe Einnahme (via Wizard)" : "Externe Ausgabe (via Wizard)"; + var result = await LinkingApi.MarkAsIntentionallyUnlinkedAsync(tx.Id, reason); + + if (result.Success) + { + // Remove from list + var list = RemainingUnlinked.ToList(); + list.Remove(tx); + RemainingUnlinked = list; + StateHasChanged(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error marking as external: {ex.Message}"); + } + } + + private async Task Complete() + { + await OnComplete.InvokeAsync(); + } +} diff --git a/src/CryptoTracker.Client/Shared/LotFlowVisualization.razor b/src/CryptoTracker.Client/Shared/LotFlowVisualization.razor new file mode 100644 index 0000000..47203f4 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LotFlowVisualization.razor @@ -0,0 +1,139 @@ +@using CryptoTracker.Shared +@using CryptoTracker.Client.Common + +
+ @if (Flow == null) + { +
Keine Flow-Daten verfügbar
+ } + else + { +
+
+ @Flow.Symbol + @FormatHelper.FormatAmount(Flow.Quantity) + + @(Flow.IsComplete ? "Vollständig" : "Unvollständig") + +
+ @if (!Flow.IsComplete && !string.IsNullOrEmpty(Flow.IncompleteReason)) + { +
+ + @Flow.IncompleteReason +
+ } +
+ +
+ @for (int i = 0; i < Flow.FlowChain.Count; i++) + { + var step = Flow.FlowChain[i]; + var isFirst = i == 0; + var isLast = i == Flow.FlowChain.Count - 1; + + @if (!isFirst) + { +
+
+
+
+ } + +
+
@GetStepIcon(step.Type)
+
+
@GetStepLabel(step.Type)
+
+ @FormatHelper.FormatAmount(step.Quantity) @step.Symbol + @step.DateTime.ToString("dd.MM.yyyy HH:mm") +
+ @if (step.TradeId.HasValue) + { +
Trade #@step.TradeId
+ } + @if (step.TransactionId.HasValue) + { +
Transaktion #@step.TransactionId
+ } +
+
Lot #@step.LotId
+
+ } +
+ +
+
+ Ursprüngliche Menge: + @FormatHelper.FormatAmount(Flow.Quantity) +
+
+ Effektive Menge: + @FormatHelper.FormatAmount(Flow.EffectiveQuantity) +
+
+ Kaufdatum: + @Flow.AcquisitionDate.ToString("dd.MM.yyyy") +
+
+ Status: + + @(Flow.AcquisitionDate <= new DateTimeOffset(2021, 2, 28, 23, 59, 59, TimeSpan.Zero) ? "Altbestand" : "Neubestand") + +
+
+ } +
+ +@code { + [Parameter] public LotFlowValidationDTO? Flow { get; set; } + + private string GetStepIcon(string type) => type switch + { + "FiatPurchase" => "💰", + "CryptoSwap" => "🔄", + "InternalTransfer" => "↔️", + "ExternalDeposit" => "📥", + "Mining" => "⛏️", + "Staking" => "🥩", + "Lending" => "🏦", + "Airdrop" => "🎁", + "Hardfork" => "🍴", + "Gift" => "🎀", + "Manual" => "✏️", + "Transfer" => "➡️", + "FiatSale" => "💵", + "CryptoSwapOut" => "🔃", + "Fee" => "📋", + _ => "📦" + }; + + private string GetStepLabel(string type) => type switch + { + "FiatPurchase" => "Kauf mit Fiat", + "CryptoSwap" => "Krypto-Tausch (Eingang)", + "InternalTransfer" => "Interner Transfer", + "ExternalDeposit" => "Externer Eingang", + "Mining" => "Mining Reward", + "Staking" => "Staking Reward", + "Lending" => "Lending Zinsen", + "Airdrop" => "Airdrop", + "Hardfork" => "Hardfork", + "Gift" => "Schenkung erhalten", + "Manual" => "Manueller Eintrag", + "Transfer" => "Transfer", + "FiatSale" => "Verkauf gegen Fiat", + "CryptoSwapOut" => "Krypto-Tausch (Ausgang)", + "Fee" => "Gebühr", + _ => type + }; + + private string GetStepClass(string type) => type switch + { + "FiatPurchase" or "Mining" or "Staking" or "Airdrop" or "Gift" or "ExternalDeposit" => "step-inflow", + "FiatSale" or "CryptoSwapOut" or "Fee" => "step-outflow", + "Transfer" or "InternalTransfer" => "step-transfer", + "CryptoSwap" => "step-swap", + _ => "" + }; +} diff --git a/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs b/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs new file mode 100644 index 0000000..31d812a --- /dev/null +++ b/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs @@ -0,0 +1,131 @@ +namespace CryptoTracker.Shared; + +/// +/// Status des AI-Services +/// +public record ServiceStatusDTO +{ + public bool IsConfigured { get; init; } + public string Message { get; init; } = ""; +} + +/// +/// Statistiken über Verknüpfungen +/// +public record LinkingStatisticsDTO +{ + public int TotalTransactions { get; init; } + public int LinkedTransactions { get; init; } + public int IntentionallyUnlinked { get; init; } + public int UnlinkedTransactions { get; init; } + public int ConfirmedLinks { get; init; } + public int UnconfirmedLinks { get; init; } + public Dictionary UnlinkedBySymbol { get; init; } = new(); + + /// + /// Prozent verknüpfte Transaktionen + /// + public decimal LinkedPercent => TotalTransactions > 0 + ? Math.Round((decimal)LinkedTransactions / TotalTransactions * 100, 1) + : 0; +} + +/// +/// Ergebnis einer Agent-Session +/// +public record LinkingSessionResultDTO +{ + public string AgentResponse { get; init; } = ""; + public bool Success { get; init; } + public IList? Proposals { get; init; } +} + +/// +/// Vorschlag für eine Verknüpfung +/// +public record TransactionLinkProposalDTO +{ + public int SendId { get; init; } + public int ReceiveId { get; init; } + public decimal Confidence { get; init; } + public string Reason { get; init; } = ""; + public UnlinkedTransactionDTO? SendTransaction { get; init; } + public UnlinkedTransactionDTO? ReceiveTransaction { get; init; } +} + +/// +/// Ergebnis der automatischen Verknüpfung +/// +public record AutoLinkResultDTO +{ + public int LinkedCount { get; init; } + public int MarkedUnlinkedCount { get; init; } + public int RemainingUnlinkedCount { get; init; } + public string Summary { get; init; } = ""; + public bool Success { get; init; } +} + +/// +/// Unverknüpfte Transaktion +/// +public record UnlinkedTransactionDTO +{ + public int Id { get; init; } + public DateTimeOffset DateTime { get; init; } + public string Type { get; init; } = ""; + public string Symbol { get; init; } = ""; + public decimal Quantity { get; init; } + public decimal QuantityAfterFee { get; init; } + public string? Comment { get; init; } + public string? Address { get; init; } + public string WalletName { get; init; } = ""; + public string? TransactionId { get; init; } + public string? Network { get; init; } + + /// + /// Ob dies ein Send ist + /// + public bool IsSend => Type?.Equals("Send", StringComparison.OrdinalIgnoreCase) == true; + + /// + /// Ob dies ein Receive ist + /// + public bool IsReceive => Type?.Equals("Receive", StringComparison.OrdinalIgnoreCase) == true; +} + +/// +/// Ergebnis einer manuellen Verknüpfung +/// +public record LinkResultDTO +{ + public bool Success { get; init; } + public string Message { get; init; } = ""; +} + +/// +/// Ergebnis der Bestätigung +/// +public record ConfirmResultDTO +{ + public int ConfirmedCount { get; init; } +} + +/// +/// Ergebnis eines Resets +/// +public record ResetResultDTO +{ + public int ResetCount { get; init; } + public int MetadataDeleted { get; init; } +} + +/// +/// Chat-Nachricht für Agent-Konversation +/// +public record ChatMessageDTO +{ + public string Role { get; init; } = ""; // "user" oder "assistant" + public string Content { get; init; } = ""; + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + public bool IsLoading { get; init; } +} diff --git a/src/CryptoTracker.Tests/Importers/AutoImport/MetamaskAutoImportTests.cs b/src/CryptoTracker.Tests/Importers/AutoImport/MetamaskAutoImportTests.cs index 9673ec6..d0d4c7c 100644 --- a/src/CryptoTracker.Tests/Importers/AutoImport/MetamaskAutoImportTests.cs +++ b/src/CryptoTracker.Tests/Importers/AutoImport/MetamaskAutoImportTests.cs @@ -43,7 +43,7 @@ private static string BuildMetamaskTransactionsCsv() for (var i = 1; i <= 10; i++) { var typ = i % 2 == 0 ? "Ausgang" : "Eingang"; - sb.AppendLine($"{i:00}.07.2024 12:0{i};{typ};ETH;BSC;0,{i}00000;0,000{i};Metamask {i}"); + sb.AppendLine($"{i:00}.07.2024 12:{i:00};{typ};ETH;BSC;0,{i}00000;0,000{i};Metamask {i}"); } return sb.ToString(); diff --git a/src/CryptoTracker.Tests/TransactionHelperTests.cs b/src/CryptoTracker.Tests/TransactionHelperTests.cs new file mode 100644 index 0000000..21bbf8c --- /dev/null +++ b/src/CryptoTracker.Tests/TransactionHelperTests.cs @@ -0,0 +1,160 @@ +using CryptoTracker.Shared; +using FluentAssertions; + +namespace CryptoTracker.Tests; + +public class TransactionHelperTests +{ + #region IsSellTrade Tests + + [Fact] + public void IsSellTrade_WithTrade_ReturnsTrue() + { + var row = CreateRow(FlowType.Trade, FlowDirection.Outflow); + + var result = IsSellTrade(row); + + result.Should().BeTrue(); + } + + [Fact] + public void IsSellTrade_WithTradeInflow_ReturnsTrue() + { + // Even inflow trades (buys) return true because they're still trades + var row = CreateRow(FlowType.Trade, FlowDirection.Inflow); + + var result = IsSellTrade(row); + + result.Should().BeTrue(); + } + + [Fact] + public void IsSellTrade_WithTransaction_ReturnsFalse() + { + var row = CreateRow(FlowType.Transaction, FlowDirection.Outflow); + + var result = IsSellTrade(row); + + result.Should().BeFalse(); + } + + [Fact] + public void IsSellTrade_WithTransactionInflow_ReturnsFalse() + { + var row = CreateRow(FlowType.Transaction, FlowDirection.Inflow); + + var result = IsSellTrade(row); + + result.Should().BeFalse(); + } + + #endregion + + #region RequiresLotAssignment Tests + + [Fact] + public void RequiresLotAssignment_TradeOutflow_ReturnsTrue() + { + var row = CreateRow(FlowType.Trade, FlowDirection.Outflow); + + var result = RequiresLotAssignment(row); + + result.Should().BeTrue(); + } + + [Fact] + public void RequiresLotAssignment_TradeInflow_ReturnsFalse() + { + // Buy trades (inflows) create new lots, they don't consume existing ones + var row = CreateRow(FlowType.Trade, FlowDirection.Inflow); + + var result = RequiresLotAssignment(row); + + result.Should().BeFalse(); + } + + [Fact] + public void RequiresLotAssignment_TransactionOutflow_ReturnsTrue() + { + // Send transactions need lot assignment to track which lots are moved + var row = CreateRow(FlowType.Transaction, FlowDirection.Outflow); + + var result = RequiresLotAssignment(row); + + result.Should().BeTrue(); + } + + [Fact] + public void RequiresLotAssignment_TransactionInflow_ReturnsFalse() + { + // Receive transactions don't need lot assignment, they receive lots from the send side + var row = CreateRow(FlowType.Transaction, FlowDirection.Inflow); + + var result = RequiresLotAssignment(row); + + result.Should().BeFalse(); + } + + #endregion + + #region Helper Methods (copied from Transaktionen.razor.cs) + + /// + /// Determines if the row represents a sell trade (crypto sold for fiat/other crypto) + /// vs. a transfer (crypto moved between own wallets) + /// + private static bool IsSellTrade(TransactionRowDTO row) + { + // A sell trade is a Trade flow type where crypto is sold + // A transfer is a Transaction flow type (Send/Receive between wallets) + return row.FlowType == FlowType.Trade; + } + + /// + /// Determines if a transaction requires lot assignment + /// + private static bool RequiresLotAssignment(TransactionRowDTO row) + { + // Sell trades need lot assignment to calculate gains + if (row.FlowType == FlowType.Trade) + { + // Only outgoing trades (sells) need lot assignment + return row.FlowDirection == FlowDirection.Outflow; + } + + // Transfers (Send transactions) need lot assignment to track which lots are moved + if (row.FlowType == FlowType.Transaction) + { + return row.FlowDirection == FlowDirection.Outflow; + } + + return false; + } + + private static TransactionRowDTO CreateRow(FlowType flowType, FlowDirection flowDirection) + { + return new TransactionRowDTO( + FlowType: flowType, + FlowDirection: flowDirection, + DateTime: DateTimeOffset.UtcNow, + FlowId: 1, + Symbol: "BTC", + Amount: 1.0m, + EuroValue: 50000m, + RateEur: 50000m, + Fee: 0m, + SourceWallet: "Wallet1", + TargetWallet: "Wallet2", + Slug: "bitcoin", + Comment: null, + HasOpposite: false, + TargetSymbol: null, + TargetAmount: null, + TargetSlug: null, + RowKey: Guid.NewGuid(), + IsHidden: false + ); + } + + #endregion +} diff --git a/src/CryptoTracker/Agent/Common/AILinkingAgentBuilder.cs b/src/CryptoTracker/Agent/Common/AILinkingAgentBuilder.cs new file mode 100644 index 0000000..97f1516 --- /dev/null +++ b/src/CryptoTracker/Agent/Common/AILinkingAgentBuilder.cs @@ -0,0 +1,105 @@ +using Azure; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenAI.Embeddings; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace CryptoTracker.Agent.Common; + +/// +/// Builder für AI-Agents mit Multi-Model-Unterstützung +/// +public class AILinkingAgentBuilder +{ + private readonly IServiceProvider _serviceProvider; + private readonly IOptions _settings; + + public AILinkingAgentBuilder(IOptions settings, IServiceProvider serviceProvider) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + /// Erstellt einen Agent mit dem Hauptmodell (z.B. gpt-5.2) + /// Für komplexe Konversationen und Steuerberatung + /// + public AIAgent BuildAgent(string agentKey) + { + return BuildAgentWithModel(agentKey, _settings.Value.OpenAiDeploymentName); + } + + /// + /// Erstellt einen Agent mit dem Fast-Modell (z.B. gpt-5-nano) + /// Für schnelle Batch-Klassifizierung und einfache Entscheidungen + /// + public AIAgent BuildFastAgent(string agentKey) + { + return BuildAgentWithModel(agentKey, _settings.Value.OpenAiFastDeploymentName); + } + + private AIAgent BuildAgentWithModel(string agentKey, string deploymentName) + { + var scope = _serviceProvider.CreateScope(); + var openAiClient = scope.ServiceProvider.GetRequiredService(); + var definitions = scope.ServiceProvider.GetServices(); + + var definition = definitions.FirstOrDefault(d => d.Metadata.Key == agentKey) + ?? throw new ArgumentException($"Agent '{agentKey}' nicht gefunden"); + + var chatClient = openAiClient + .GetChatClient(deploymentName) + .AsIChatClient(); + + var tools = definition.Tools + .Select(t => AIFunctionFactory.Create( + t.GetToolRunner(), + t.GetToolName(), + t.GetToolDescription(), + t.GetJsonSerializerContext() != null + ? new JsonSerializerOptions + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + t.GetJsonSerializerContext()!.Options.TypeInfoResolver, + new DefaultJsonTypeInfoResolver()) + } + : null)) + .ToArray(); + + var chatAgent = chatClient.AsAIAgent(new ChatClientAgentOptions + { + Name = definition.Metadata.Key, + Description = definition.Metadata.DisplayName, + ChatOptions = new ChatOptions + { + Instructions = definition.PromptDefinition.SystemPrompt, + Tools = tools + } + }); + + return chatAgent.AsBuilder().Build(); + } + + /// + /// Gibt einen Embedding-Client für Ähnlichkeitssuche zurück + /// + public EmbeddingClient GetEmbeddingClient() + { + var scope = _serviceProvider.CreateScope(); + var openAiClient = scope.ServiceProvider.GetRequiredService(); + return openAiClient.GetEmbeddingClient(_settings.Value.OpenAiEmbeddingDeploymentName); + } + + /// + /// Prüft ob die OpenAI-Konfiguration vorhanden ist + /// + public bool IsConfigured() + { + return _settings.Value.IsConfigured; + } +} diff --git a/src/CryptoTracker/Agent/Common/AgentDefinitionBase.cs b/src/CryptoTracker/Agent/Common/AgentDefinitionBase.cs new file mode 100644 index 0000000..2ea5abe --- /dev/null +++ b/src/CryptoTracker/Agent/Common/AgentDefinitionBase.cs @@ -0,0 +1,21 @@ +namespace CryptoTracker.Agent.Common; + +/// +/// Basisklasse für Agent-Definitionen +/// +public abstract class AgentDefinitionBase : IAgentDefinition +{ + public AgentMetadata Metadata { get; } + public AgentPromptDefinition PromptDefinition { get; } + public IReadOnlyList Tools { get; } + + protected AgentDefinitionBase( + AgentMetadata metadata, + AgentPromptDefinition promptDefinition, + IReadOnlyList tools) + { + Metadata = metadata; + PromptDefinition = promptDefinition; + Tools = tools; + } +} diff --git a/src/CryptoTracker/Agent/Common/IAgentDefinition.cs b/src/CryptoTracker/Agent/Common/IAgentDefinition.cs new file mode 100644 index 0000000..1c910e6 --- /dev/null +++ b/src/CryptoTracker/Agent/Common/IAgentDefinition.cs @@ -0,0 +1,38 @@ +namespace CryptoTracker.Agent.Common; + +/// +/// Metadaten für einen Agent +/// +public record AgentMetadata( + string Key, + string DisplayName, + string Description +); + +/// +/// Prompt-Definition für einen Agent +/// +public record AgentPromptDefinition( + string SystemPrompt +); + +/// +/// Interface für Agent-Definitionen +/// +public interface IAgentDefinition +{ + /// + /// Metadaten des Agents (Key, Name, Beschreibung) + /// + AgentMetadata Metadata { get; } + + /// + /// Prompt-Definition (System-Prompt) + /// + AgentPromptDefinition PromptDefinition { get; } + + /// + /// Tools die dem Agent zur Verfügung stehen + /// + IReadOnlyList Tools { get; } +} diff --git a/src/CryptoTracker/Agent/Common/IAgentTool.cs b/src/CryptoTracker/Agent/Common/IAgentTool.cs new file mode 100644 index 0000000..49f80fd --- /dev/null +++ b/src/CryptoTracker/Agent/Common/IAgentTool.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace CryptoTracker.Agent.Common; + +/// +/// Interface für Agent-Tools (Function Calling) +/// +public interface IAgentTool +{ + /// + /// Gibt den Delegate zurück, der vom Agent aufgerufen wird + /// + Delegate GetToolRunner(); + + /// + /// Name des Tools (für Function Calling, snake_case) + /// + string GetToolName(); + + /// + /// Beschreibung des Tools (für LLM-Kontext) + /// + string GetToolDescription(); + + /// + /// Optional: JsonSerializerContext für komplexe Typen + /// + JsonSerializerContext? GetJsonSerializerContext() => null; +} diff --git a/src/CryptoTracker/Agent/Common/OpenAISettings.cs b/src/CryptoTracker/Agent/Common/OpenAISettings.cs new file mode 100644 index 0000000..c7b9c24 --- /dev/null +++ b/src/CryptoTracker/Agent/Common/OpenAISettings.cs @@ -0,0 +1,43 @@ +namespace CryptoTracker.Agent.Common; + +/// +/// Konfiguration für Azure OpenAI. +/// Property-Namen entsprechen den Environment-Variablen / secrets.json Keys. +/// +public class OpenAISettings +{ + /// + /// Azure OpenAI Endpoint URL + /// + public string OpenAiEndpoint { get; set; } = string.Empty; + + /// + /// Hauptmodell für komplexe Agent-Konversationen (z.B. gpt-5.2) + /// + public string OpenAiDeploymentName { get; set; } = "gpt-5.2"; + + /// + /// Schnelles Modell für Batch-Klassifizierung (z.B. gpt-5-nano) + /// + public string OpenAiFastDeploymentName { get; set; } = "gpt-5-nano"; + + /// + /// Embedding-Modell für Ähnlichkeitssuche + /// + public string OpenAiEmbeddingDeploymentName { get; set; } = "text-embedding-3-large"; + + /// + /// Dimensionen der Embedding-Vektoren + /// + public int OpenAiEmbeddingVectorDimensions { get; set; } = 1536; + + /// + /// API-Key (optional, falls nicht DefaultAzureCredential verwendet wird) + /// + public string? OpenAiKey { get; set; } + + /// + /// Prüft ob die OpenAI-Konfiguration vollständig ist + /// + public bool IsConfigured => !string.IsNullOrEmpty(OpenAiEndpoint) && !string.IsNullOrEmpty(OpenAiDeploymentName); +} diff --git a/src/CryptoTracker/Agent/Definitions/TransactionLinkingAgentDefinition.cs b/src/CryptoTracker/Agent/Definitions/TransactionLinkingAgentDefinition.cs new file mode 100644 index 0000000..13bcd9c --- /dev/null +++ b/src/CryptoTracker/Agent/Definitions/TransactionLinkingAgentDefinition.cs @@ -0,0 +1,109 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Agent.Tools; + +namespace CryptoTracker.Agent.Definitions; + +/// +/// Agent-Definition für Transaktions-Verknüpfung +/// +public sealed class TransactionLinkingAgentDefinition : AgentDefinitionBase +{ + public const string KEY = "transaction-linking"; + + private const string SystemPrompt = """ + ROLLE + Du bist ein Experte für Kryptowährungs-Transaktionsanalyse. Deine Aufgabe ist es, + Send- und Receive-Transaktionen intelligent zu verknüpfen für die österreichische Steuerdokumentation. + + KONTEXT + - Transaktionen zwischen eigenen Wallets haben oft leicht unterschiedliche Zeiten (Blockchain-Bestätigungszeit) + - Der Betrag nach Gebühren (QuantityAfterFee) beim Send sollte dem Receive.Quantity entsprechen + - Kommentare können wichtige Hinweise auf die Herkunft geben + - Adressen können helfen, Wallets zu identifizieren + + REGELN FÜR VERKNÜPFUNGEN + + 1. **Externe Einnahmen (NICHT verknüpfen - markiere als intentionally_unlinked)**: + - "Staking Rewards", "ETH 2.0 Staking Rewards" → Externe Einnahme + - "Airdrop", "Bonus", "Referral" → Externe Einnahme + - "Mining", "Lending Interest" → Externe Einnahme + - "div. Käufe", "Kauf", "Buy" → Kommt von Fiat-Kauf, kein Transfer-Gegenstück + - Jede Receive-Transaktion OHNE passendes Send könnte eine externe Einnahme sein + + 2. **Interne Transfers (VERKNÜPFEN)**: + - Kommentare wie "PC-Wallet", "Ledger", Wallet-Namen → Interner Transfer + - Gleiche oder ähnliche Adresse → Wahrscheinlich verknüpft + - Zeit innerhalb von ~30 Minuten + ähnlicher Betrag → Hohe Wahrscheinlichkeit + - Send.QuantityAfterFee ≈ Receive.Quantity (kleine Differenz durch Gebühren möglich) + + 3. **Fehlende Gegenstücke analysieren**: + - Wenn ein Send keinen passenden Receive hat, könnte das Ziel-Wallet nicht importiert sein + - Wenn ein Receive von einer bekannten eigenen Adresse kommt, aber kein Send existiert, + schlage vor, dass das Quell-Wallet fehlt + + IMPORTFEHLER ERKENNEN + - UTC vs. Lokalzeit-Differenzen (z.B. +1h, +2h Unterschied) + - Wenn Zeit um genau 1-2 Stunden abweicht, aber Betrag exakt passt → Zeitzone-Problem + - Speichere erkannte Fehler-Muster im Gedächtnis (ImportErrorPattern) + + TOOLS + - `get_unlinked_transactions`: Lade unverknüpfte Transaktionen (mit Paginierung) + - `get_transaction_details`: Lade Details zu spezifischen Transaktionen + - `find_matching_transactions`: Hilfstool - sucht potentielle Gegenstücke anhand von Zeit und Betrag + - `link_transactions`: Verknüpfe Send mit Receive + - `mark_intentionally_unlinked`: Markiere als externe Einnahme (kein Gegenstück) + - `save_memory`: Speichere Regeln für zukünftige Verwendung + - `get_memory`: Lade gespeicherte Regeln + + WICHTIG ZU `find_matching_transactions`: + Dieses Tool ist nur eine HILFE und findet oft KEINE Matches, weil es nur einfache + Kriterien (Zeit, Betrag) verwendet. Du musst SELBST die Transaktionen analysieren: + + 1. Lade mit `get_unlinked_transactions` ALLE unverknüpften Transaktionen (Sends und Receives) + 2. Analysiere die Daten SELBST: Vergleiche Symbole, Beträge, Zeitpunkte, Kommentare, Wallets + 3. Finde passende Paare durch DEINE Analyse - verlasse dich NICHT auf `find_matching_transactions` + 4. Das Tool `find_matching_transactions` kann als zusätzliche Validierung genutzt werden, + aber DU bist der Experte der die Zusammenhänge erkennt! + + WORKFLOW + 1. Lade zunächst gespeicherte Regeln (get_memory) + 2. Lade ALLE unverknüpften Transaktionen mit `get_unlinked_transactions` + (mehrere Aufrufe mit offset falls nötig) + 3. Gruppiere die Transaktionen nach Symbol + 4. Für jedes Symbol: Analysiere Sends und Receives SELBST: + - Vergleiche Beträge (Send.QuantityAfterFee ≈ Receive.Quantity) + - Vergleiche Zeitpunkte (innerhalb von Minuten bis Stunden) + - Prüfe Kommentare auf Hinweise (Wallet-Namen, "Transfer", etc.) + - Erkenne Muster (z.B. regelmäßige Transfers zwischen zwei Wallets) + 5. Bei gefundenen Paaren: Verknüpfe mit `link_transactions` + 6. Bei Receives ohne passendes Send: Prüfe ob externe Einnahme (Staking, Airdrop, etc.) + 7. Speichere neue Regeln wenn der Benutzer eine wiederkehrende Entscheidung trifft + + KONFIDENZ-SCHWELLEN + - >= 0.9: Automatisch verknüpfen (exakte Zeit + Betrag + Kontext passt) + - 0.7-0.9: Vorschlagen, aber Benutzer fragen + - < 0.7: Nur als Option anzeigen + + OUTPUT + Antworte immer auf Deutsch. Erkläre deine Entscheidungen kurz und prägnant. + Bei Rückfragen an den Benutzer, formuliere klare Ja/Nein-Fragen oder Multiple-Choice. + Gib bei Verknüpfungen immer an: Symbol, Menge, Quell-Wallet, Ziel-Wallet, Zeitdifferenz. + """; + + public TransactionLinkingAgentDefinition( + GetUnlinkedTransactionsTool getUnlinkedTool, + GetTransactionDetailsTool getDetailsTool, + FindMatchingTransactionsTool findMatchingTool, + LinkTransactionsTool linkTool, + MarkAsIntentionallyUnlinkedTool markUnlinkedTool, + SaveAgentMemoryTool saveMemoryTool, + GetAgentMemoryTool getMemoryTool) + : base( + new AgentMetadata(KEY, "Transaktions-Verknüpfungs-Assistent", + "Verknüpft Send/Receive-Transaktionen intelligent für Steuerdokumentation"), + new AgentPromptDefinition(SystemPrompt), + [getUnlinkedTool, getDetailsTool, findMatchingTool, linkTool, + markUnlinkedTool, saveMemoryTool, getMemoryTool]) + { + } +} diff --git a/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs b/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs new file mode 100644 index 0000000..653955d --- /dev/null +++ b/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs @@ -0,0 +1,334 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Agent.Definitions; +using CryptoTracker.Entities; +using Microsoft.Agents.AI; +using Microsoft.EntityFrameworkCore; + +namespace CryptoTracker.Agent.Services; + +/// +/// Service für KI-gestützte Transaktionsverknüpfung +/// +public class TransactionLinkingService +{ + private readonly AILinkingAgentBuilder _agentBuilder; + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILogger _logger; + + public TransactionLinkingService( + AILinkingAgentBuilder agentBuilder, + CryptoTrackerDbContext dbContext, + ILogger logger) + { + _agentBuilder = agentBuilder; + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Prüft ob der AI-Service konfiguriert ist + /// + public bool IsConfigured => _agentBuilder.IsConfigured(); + + /// + /// Startet eine neue Linking-Session + /// + public async Task StartSessionAsync(CancellationToken ct = default) + { + if (!IsConfigured) + { + return new LinkingSessionResult + { + AgentResponse = "AI-Service nicht konfiguriert. Bitte OpenAI-Einstellungen in appsettings.json prüfen." + }; + } + + try + { + var agent = _agentBuilder.BuildAgent(TransactionLinkingAgentDefinition.KEY); + + var result = await agent.RunAsync( + "Starte eine neue Verknüpfungs-Session. " + + "Lade zuerst deine gespeicherten Regeln und dann die unverknüpften Transaktionen. " + + "Gib mir einen Überblick über die Situation: Wie viele unverknüpfte Transaktionen gibt es? " + + "Welche Symbole sind betroffen? Gibt es offensichtliche Muster?", + cancellationToken: ct); + + return new LinkingSessionResult + { + AgentResponse = result.Text ?? "Session gestartet.", + Success = true + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler beim Starten der Agent-Session"); + return new LinkingSessionResult + { + AgentResponse = $"Fehler beim Starten der Session: {ex.Message}", + Success = false + }; + } + } + + /// + /// Setzt eine Konversation mit dem Agent fort + /// + public async Task ContinueSessionAsync( + string userMessage, + CancellationToken ct = default) + { + if (!IsConfigured) + { + return new LinkingSessionResult + { + AgentResponse = "AI-Service nicht konfiguriert." + }; + } + + try + { + var agent = _agentBuilder.BuildAgent(TransactionLinkingAgentDefinition.KEY); + + var result = await agent.RunAsync(userMessage, cancellationToken: ct); + + return new LinkingSessionResult + { + AgentResponse = result.Text ?? "", + Success = true + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler beim Fortsetzen der Agent-Session"); + return new LinkingSessionResult + { + AgentResponse = $"Fehler: {ex.Message}", + Success = false + }; + } + } + + /// + /// Führt automatische Verknüpfung durch (ohne Benutzerinteraktion) + /// + public async Task RunAutomaticLinkingAsync(CancellationToken ct = default) + { + if (!IsConfigured) + { + return new AutoLinkResult + { + Summary = "AI-Service nicht konfiguriert.", + Success = false + }; + } + + try + { + var agent = _agentBuilder.BuildAgent(TransactionLinkingAgentDefinition.KEY); + + var startTime = DateTimeOffset.UtcNow; + + var result = await agent.RunAsync( + """ + Führe automatische Verknüpfung durch: + 1. Lade gespeicherte Regeln (get_memory) + 2. Lade alle unverknüpften Transaktionen + 3. Für jede Transaktion: + - Prüfe ob bekannte Skip-Patterns zutreffen → mark_intentionally_unlinked + - Suche passende Gegenstücke (find_matching_transactions) + - Bei Konfidenz >= 0.9: Verknüpfe automatisch (link_transactions) + 4. Gib mir eine detaillierte Zusammenfassung: + - Wie viele wurden verknüpft? + - Wie viele als externe Einnahme markiert? + - Wie viele bleiben unverknüpft? + - Welche brauchen manuelle Prüfung? + """, + cancellationToken: ct); + + // Zähle Ergebnisse + var linkedCount = await _dbContext.TransactionLinkMetadata + .CountAsync(m => m.LinkedAt > startTime && + !m.LinkType.HasFlag(TransactionLinkType.IntentionallyUnlinked), ct); + + var markedUnlinkedCount = await _dbContext.TransactionLinkMetadata + .CountAsync(m => m.LinkedAt > startTime && + m.LinkType.HasFlag(TransactionLinkType.IntentionallyUnlinked), ct); + + var remainingUnlinked = await _dbContext.CryptoTransactions + .CountAsync(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked, ct); + + return new AutoLinkResult + { + LinkedCount = linkedCount, + MarkedUnlinkedCount = markedUnlinkedCount, + RemainingUnlinkedCount = remainingUnlinked, + Summary = result.Text ?? "", + Success = true + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler bei automatischer Verknüpfung"); + return new AutoLinkResult + { + Summary = $"Fehler: {ex.Message}", + Success = false + }; + } + } + + /// + /// Gibt Statistiken über unverknüpfte Transaktionen zurück + /// + public async Task GetStatisticsAsync(CancellationToken ct = default) + { + var totalTransactions = await _dbContext.CryptoTransactions.CountAsync(ct); + + var linkedTransactions = await _dbContext.CryptoTransactions + .CountAsync(t => t.OppositeTransactionId != null, ct); + + var intentionallyUnlinked = await _dbContext.CryptoTransactions + .CountAsync(t => t.IsIntentionallyUnlinked, ct); + + var unlinkedTransactions = await _dbContext.CryptoTransactions + .CountAsync(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked, ct); + + var confirmedLinks = await _dbContext.TransactionLinkMetadata + .CountAsync(m => m.IsConfirmed, ct); + + var unconfirmedLinks = await _dbContext.TransactionLinkMetadata + .CountAsync(m => !m.IsConfirmed && !m.LinkType.HasFlag(TransactionLinkType.IntentionallyUnlinked), ct); + + var bySymbol = await _dbContext.CryptoTransactions + .Where(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked) + .GroupBy(t => t.Symbol) + .Select(g => new { Symbol = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .Take(10) + .ToListAsync(ct); + + return new LinkingStatistics + { + TotalTransactions = totalTransactions, + LinkedTransactions = linkedTransactions, + IntentionallyUnlinked = intentionallyUnlinked, + UnlinkedTransactions = unlinkedTransactions, + ConfirmedLinks = confirmedLinks, + UnconfirmedLinks = unconfirmedLinks, + UnlinkedBySymbol = bySymbol.ToDictionary(x => x.Symbol, x => x.Count) + }; + } + + /// + /// Setzt alle Verknüpfungen zurück + /// + public async Task ResetAllLinksAsync( + bool keepManualLinks = true, + bool resetIntentionallyUnlinked = false, + CancellationToken ct = default) + { + var resetCount = 0; + + // 1. Finde zu löschende Metadaten + var metadataQuery = _dbContext.TransactionLinkMetadata.AsQueryable(); + + if (keepManualLinks) + { + metadataQuery = metadataQuery.Where(m => + !(m.IsConfirmed && m.LinkType.HasFlag(TransactionLinkType.Manual))); + } + + if (!resetIntentionallyUnlinked) + { + metadataQuery = metadataQuery.Where(m => + !m.LinkType.HasFlag(TransactionLinkType.IntentionallyUnlinked)); + } + + var metadataToDelete = await metadataQuery.ToListAsync(ct); + var transactionIdsToReset = metadataToDelete.Select(m => m.TransactionId).Distinct().ToHashSet(); + + // 2. Setze OppositeTransactionId/OppositeWalletId zurück + var allLinkedTransactions = await _dbContext.CryptoTransactions + .Where(t => t.OppositeTransactionId != null) + .ToListAsync(ct); + + foreach (var tx in allLinkedTransactions) + { + var shouldReset = transactionIdsToReset.Contains(tx.Id) || + (tx.OppositeTransactionId.HasValue && transactionIdsToReset.Contains(tx.OppositeTransactionId.Value)); + + if (shouldReset) + { + tx.OppositeTransactionId = null; + tx.OppositeWalletId = null; + resetCount++; + } + } + + // 3. Setze IsIntentionallyUnlinked zurück + if (resetIntentionallyUnlinked) + { + var intentionallyUnlinked = await _dbContext.CryptoTransactions + .Where(t => t.IsIntentionallyUnlinked) + .ToListAsync(ct); + + foreach (var tx in intentionallyUnlinked) + { + tx.IsIntentionallyUnlinked = false; + } + } + + // 4. Lösche Metadaten + _dbContext.TransactionLinkMetadata.RemoveRange(metadataToDelete); + + await _dbContext.SaveChangesAsync(ct); + + return new ResetResult + { + ResetCount = resetCount, + MetadataDeleted = metadataToDelete.Count + }; + } +} + +public record LinkingSessionResult +{ + public string AgentResponse { get; init; } = ""; + public bool Success { get; init; } + public List? Proposals { get; init; } +} + +public record TransactionLinkProposal +{ + public int SendId { get; init; } + public int ReceiveId { get; init; } + public decimal Confidence { get; init; } + public string Reason { get; init; } = ""; +} + +public record AutoLinkResult +{ + public int LinkedCount { get; init; } + public int MarkedUnlinkedCount { get; init; } + public int RemainingUnlinkedCount { get; init; } + public string Summary { get; init; } = ""; + public bool Success { get; init; } +} + +public record LinkingStatistics +{ + public int TotalTransactions { get; init; } + public int LinkedTransactions { get; init; } + public int IntentionallyUnlinked { get; init; } + public int UnlinkedTransactions { get; init; } + public int ConfirmedLinks { get; init; } + public int UnconfirmedLinks { get; init; } + public Dictionary UnlinkedBySymbol { get; init; } = new(); +} + +public record ResetResult +{ + public int ResetCount { get; init; } + public int MetadataDeleted { get; init; } +} diff --git a/src/CryptoTracker/Agent/Tools/FindMatchingTransactionsTool.cs b/src/CryptoTracker/Agent/Tools/FindMatchingTransactionsTool.cs new file mode 100644 index 0000000..7d69ec5 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/FindMatchingTransactionsTool.cs @@ -0,0 +1,156 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Suchen von potentiellen Gegenstücken für eine Transaktion +/// +public sealed class FindMatchingTransactionsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public FindMatchingTransactionsTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => FindMatchingTransactionsAsync; + public string GetToolName() => "find_matching_transactions"; + public string GetToolDescription() => """ + Sucht potentielle Gegenstücke für eine Transaktion basierend auf Zeit und Betrag. + HINWEIS: Dieses Tool ist nur eine HILFE und findet oft KEINE Matches! + Du solltest die Transaktionen SELBST analysieren und vergleichen. + + Parameter: + - transactionId: Datenbank-ID der Transaktion für die Gegenstücke gesucht werden + - timeWindowMinutes: Zeitfenster in Minuten (default: 60) + - amountTolerance: Toleranz für Betragsabweichung in Prozent (default: 0.01 = 1%) + + Gibt Liste von potentiellen Matches mit Konfidenzwerten zurück. + Die "Id" in den Ergebnissen ist die Datenbank-ID für link_transactions. + """; + + private async Task FindMatchingTransactionsAsync( + [Description("Datenbank-ID der Transaktion")] int transactionId, + [Description("Zeitfenster in Minuten")] int timeWindowMinutes = 60, + [Description("Toleranz für Betragsabweichung (0.01 = 1%)")] decimal amountTolerance = 0.01m) + { + var sourceTransaction = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == transactionId); + + if (sourceTransaction == null) + return JsonSerializer.Serialize(new { error = "Transaktion nicht gefunden" }); + + if (sourceTransaction.OppositeTransactionId != null) + return JsonSerializer.Serialize(new { error = "Transaktion ist bereits verknüpft" }); + + // Suche nach entgegengesetztem Typ + var oppositeType = sourceTransaction.TransactionType == TransactionType.Send + ? TransactionType.Receive + : TransactionType.Send; + + var timeWindow = TimeSpan.FromMinutes(timeWindowMinutes); + var minTime = sourceTransaction.DateTime - timeWindow; + var maxTime = sourceTransaction.DateTime + timeWindow; + + // Bei Send: Vergleiche QuantityAfterFee mit Receive.Quantity + // Bei Receive: Vergleiche Quantity mit Send.QuantityAfterFee + var compareAmount = sourceTransaction.TransactionType == TransactionType.Send + ? sourceTransaction.QuantityAfterFee + : sourceTransaction.Quantity; + + var minAmount = compareAmount * (1 - amountTolerance); + var maxAmount = compareAmount * (1 + amountTolerance); + + var candidates = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.Id != transactionId) + .Where(t => t.OppositeTransactionId == null) + .Where(t => !t.IsIntentionallyUnlinked) + .Where(t => t.TransactionType == oppositeType) + .Where(t => t.Symbol == sourceTransaction.Symbol) + .Where(t => t.DateTime >= minTime && t.DateTime <= maxTime) + .ToListAsync(); + + var matches = candidates + .Select(t => + { + // Vergleichsbetrag je nach Typ + var candidateAmount = t.TransactionType == TransactionType.Send + ? t.QuantityAfterFee + : t.Quantity; + + var amountDiff = Math.Abs(candidateAmount - compareAmount); + var amountMatch = amountDiff <= compareAmount * amountTolerance; + var timeDiff = Math.Abs((t.DateTime - sourceTransaction.DateTime).TotalMinutes); + + // Konfidenzberechnung + var confidence = 0.0m; + if (amountMatch) + { + // Basiswert für Betragsübereinstimmung + confidence = 0.5m; + + // Bonus für exakte Übereinstimmung + if (amountDiff == 0) + confidence += 0.2m; + + // Bonus für zeitliche Nähe + if (timeDiff <= 5) + confidence += 0.2m; + else if (timeDiff <= 15) + confidence += 0.1m; + + // Bonus wenn gleiche Adresse + if (!string.IsNullOrEmpty(t.Address) && + !string.IsNullOrEmpty(sourceTransaction.Address) && + t.Address == sourceTransaction.Address) + confidence += 0.1m; + } + + return new + { + t.Id, + DateTime = t.DateTime.ToString("yyyy-MM-dd HH:mm:ss"), + Type = t.TransactionType.ToString(), + t.Symbol, + t.Quantity, + t.QuantityAfterFee, + t.Comment, + t.Address, + WalletName = t.Wallet.Name, + TimeDiffMinutes = Math.Round(timeDiff, 1), + AmountDiff = amountDiff, + Confidence = Math.Min(1.0m, confidence) + }; + }) + .Where(m => m.Confidence > 0) + .OrderByDescending(m => m.Confidence) + .ThenBy(m => m.TimeDiffMinutes) + .Take(10) + .ToList(); + + var result = new + { + sourceTransaction = new + { + sourceTransaction.Id, + DateTime = sourceTransaction.DateTime.ToString("yyyy-MM-dd HH:mm:ss"), + Type = sourceTransaction.TransactionType.ToString(), + sourceTransaction.Symbol, + sourceTransaction.Quantity, + sourceTransaction.QuantityAfterFee, + sourceTransaction.Comment, + WalletName = sourceTransaction.Wallet.Name + }, + matches + }; + + return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = false }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/GetAgentMemoryTool.cs b/src/CryptoTracker/Agent/Tools/GetAgentMemoryTool.cs new file mode 100644 index 0000000..348dac3 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/GetAgentMemoryTool.cs @@ -0,0 +1,72 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Laden von Agent-Regeln aus dem Gedächtnis +/// +public sealed class GetAgentMemoryTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public GetAgentMemoryTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => GetAgentMemoryAsync; + public string GetToolName() => "get_memory"; + public string GetToolDescription() => """ + Lädt gespeicherte Regeln aus dem Agent-Gedächtnis. + Rufe dies zu Beginn einer Session auf um bekannte Regeln zu laden. + + Parameter: + - memoryType: Optional, filtert nach Art (SkipPattern, WalletMapping, etc.) + + Gibt alle gespeicherten Regeln zurück. + """; + + private async Task GetAgentMemoryAsync( + [Description("Optional: SkipPattern, WalletMapping, AddressMapping, UserDecision, ImportErrorPattern")] string? memoryType = null) + { + const string agentKey = "transaction-linking"; + + var query = _dbContext.AgentMemories + .Where(m => m.AgentKey == agentKey); + + if (!string.IsNullOrEmpty(memoryType) && Enum.TryParse(memoryType, true, out var type)) + { + query = query.Where(m => m.MemoryType == type); + } + + var memories = await query + .OrderByDescending(m => m.UsageCount) + .ThenByDescending(m => m.CreatedAt) + .Select(m => new + { + m.Id, + MemoryType = m.MemoryType.ToString(), + m.Key, + m.Value, + m.Description, + CreatedAt = m.CreatedAt.ToString("yyyy-MM-dd HH:mm"), + m.UsageCount + }) + .ToListAsync(); + + var summary = memories + .GroupBy(m => m.MemoryType) + .ToDictionary(g => g.Key, g => g.Count()); + + return JsonSerializer.Serialize(new + { + totalCount = memories.Count, + summary, + memories + }, new JsonSerializerOptions { WriteIndented = false }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/GetTransactionDetailsTool.cs b/src/CryptoTracker/Agent/Tools/GetTransactionDetailsTool.cs new file mode 100644 index 0000000..d6c0d20 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/GetTransactionDetailsTool.cs @@ -0,0 +1,90 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Laden von Transaktionsdetails +/// +public sealed class GetTransactionDetailsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public GetTransactionDetailsTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => GetTransactionDetailsAsync; + public string GetToolName() => "get_transaction_details"; + public string GetToolDescription() => """ + Lädt Details zu einer oder mehreren Transaktionen. + Parameter: + - transactionIds: Komma-separierte Liste von Datenbank-IDs (z.B. "1,2,3") + + Gibt JSON mit vollständigen Details zurück inkl. verknüpfter Transaktion falls vorhanden. + Die "Id" ist die Datenbank-ID, "TxHash" ist der Blockchain Transaction-Hash. + """; + + private async Task GetTransactionDetailsAsync( + [Description("Komma-separierte Liste von IDs, z.B. '1,2,3'")] string transactionIds) + { + var ids = transactionIds + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var id) ? id : (int?)null) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + + if (!ids.Any()) + return JsonSerializer.Serialize(new { error = "Keine gültigen IDs angegeben" }); + + var transactions = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Include(t => t.OppositeTransaction) + .ThenInclude(ot => ot!.Wallet) + .Include(t => t.LinkMetadata) + .Where(t => ids.Contains(t.Id)) + .Select(t => new + { + t.Id, // Datenbank-ID + DateTime = t.DateTime.ToString("yyyy-MM-dd HH:mm:ss zzz"), + Type = t.TransactionType.ToString(), + t.Symbol, + t.Quantity, + t.QuantityAfterFee, + t.Fee, + t.Comment, + t.Address, + TxHash = t.TransactionId, // Blockchain TX-Hash + t.Network, + WalletId = t.WalletId, + WalletName = t.Wallet.Name, + t.IsIntentionallyUnlinked, + OppositeTransaction = t.OppositeTransaction != null ? new + { + t.OppositeTransaction.Id, + DateTime = t.OppositeTransaction.DateTime.ToString("yyyy-MM-dd HH:mm:ss zzz"), + Type = t.OppositeTransaction.TransactionType.ToString(), + t.OppositeTransaction.Symbol, + t.OppositeTransaction.Quantity, + t.OppositeTransaction.QuantityAfterFee, + WalletName = t.OppositeTransaction.Wallet.Name + } : null, + LinkMetadata = t.LinkMetadata != null ? new + { + LinkType = t.LinkMetadata.LinkType.ToString(), + t.LinkMetadata.Confidence, + t.LinkMetadata.Reason, + t.LinkMetadata.IsConfirmed, + LinkedAt = t.LinkMetadata.LinkedAt.ToString("yyyy-MM-dd HH:mm:ss") + } : null + }) + .ToListAsync(); + + return JsonSerializer.Serialize(transactions, new JsonSerializerOptions { WriteIndented = false }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/GetUnlinkedTransactionsTool.cs b/src/CryptoTracker/Agent/Tools/GetUnlinkedTransactionsTool.cs new file mode 100644 index 0000000..441863b --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/GetUnlinkedTransactionsTool.cs @@ -0,0 +1,94 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Laden unverknüpfter Transaktionen +/// +public sealed class GetUnlinkedTransactionsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public GetUnlinkedTransactionsTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => GetUnlinkedTransactionsAsync; + public string GetToolName() => "get_unlinked_transactions"; + public string GetToolDescription() => """ + Lädt unverknüpfte Transaktionen aus der Datenbank. + Parameter: + - type: "send", "receive" oder "all" (default: "all") + - symbol: Optional, z.B. "ETH", "BTC" zum Filtern nach Coin + - limit: Max. Anzahl Ergebnisse (default: 50, max: 200) + - offset: Für Paginierung (default: 0) + - includeIntentionallyUnlinked: Auch bewusst unverknüpfte einschließen (default: false) + + Gibt JSON-Array zurück mit: + - Id: Datenbank-ID (diese ID für link_transactions verwenden!) + - DateTime, Type, Symbol, Quantity, QuantityAfterFee, Comment, Address, WalletName + - TxHash: Blockchain Transaction-Hash (kann leer sein, nur zur Info) + """; + + private async Task GetUnlinkedTransactionsAsync( + [Description("Filter: 'send', 'receive' oder 'all'")] string type = "all", + [Description("Symbol-Filter, z.B. 'ETH', 'BTC'")] string? symbol = null, + [Description("Max. Anzahl Ergebnisse (1-200)")] int limit = 50, + [Description("Offset für Paginierung")] int offset = 0, + [Description("Auch bewusst unverknüpfte einschließen")] bool includeIntentionallyUnlinked = false) + { + limit = Math.Clamp(limit, 1, 200); + offset = Math.Max(0, offset); + + var query = _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.OppositeTransactionId == null); + + if (!includeIntentionallyUnlinked) + query = query.Where(t => !t.IsIntentionallyUnlinked); + + if (type.Equals("send", StringComparison.OrdinalIgnoreCase)) + query = query.Where(t => t.TransactionType == TransactionType.Send); + else if (type.Equals("receive", StringComparison.OrdinalIgnoreCase)) + query = query.Where(t => t.TransactionType == TransactionType.Receive); + + if (!string.IsNullOrEmpty(symbol)) + query = query.Where(t => t.Symbol == symbol); + + var totalCount = await query.CountAsync(); + + var transactions = await query + .OrderBy(t => t.DateTime) + .Skip(offset) + .Take(limit) + .Select(t => new + { + t.Id, // Datenbank-ID - diese für link_transactions verwenden! + DateTime = t.DateTime.ToString("yyyy-MM-dd HH:mm:ss"), + Type = t.TransactionType.ToString(), + t.Symbol, + t.Quantity, + t.QuantityAfterFee, + t.Comment, + t.Address, + TxHash = t.TransactionId, // Blockchain TX-Hash (kann leer sein) + WalletName = t.Wallet.Name + }) + .ToListAsync(); + + var result = new + { + totalCount, + offset, + limit, + transactions + }; + + return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = false }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/LinkTransactionsTool.cs b/src/CryptoTracker/Agent/Tools/LinkTransactionsTool.cs new file mode 100644 index 0000000..c3efdf1 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/LinkTransactionsTool.cs @@ -0,0 +1,115 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Verknüpfen von zwei Transaktionen +/// +public sealed class LinkTransactionsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public LinkTransactionsTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => LinkTransactionsAsync; + public string GetToolName() => "link_transactions"; + public string GetToolDescription() => """ + Verknüpft zwei Transaktionen miteinander (Send mit Receive). + Parameter: + - sendTransactionId: Datenbank-ID der Send-Transaktion (das "Id" Feld aus get_unlinked_transactions) + - receiveTransactionId: Datenbank-ID der Receive-Transaktion (das "Id" Feld aus get_unlinked_transactions) + - linkType: Wie wurde die Verknüpfung gefunden? Flags kombinierbar: + 1=TimeAndAmount, 2=AIAssisted, 4=Direct, 8=Indirect, 16=Automatic + - confidence: Konfidenz 0.0-1.0 (wie sicher ist die Verknüpfung?) + - reason: Begründung für die Verknüpfung + + Gibt Erfolg/Fehler-Status zurück. + """; + + private async Task LinkTransactionsAsync( + [Description("Datenbank-ID der Send-Transaktion")] int sendTransactionId, + [Description("Datenbank-ID der Receive-Transaktion")] int receiveTransactionId, + [Description("Link-Typ Flags (1=TimeAndAmount, 2=AIAssisted, 4=Direct, 8=Indirect, 16=Automatic)")] int linkType, + [Description("Konfidenz 0.0-1.0")] decimal confidence, + [Description("Begründung für die Verknüpfung")] string reason) + { + var send = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == sendTransactionId); + + var receive = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == receiveTransactionId); + + if (send == null) + return JsonSerializer.Serialize(new { success = false, error = $"Send-Transaktion {sendTransactionId} nicht gefunden" }); + + if (receive == null) + return JsonSerializer.Serialize(new { success = false, error = $"Receive-Transaktion {receiveTransactionId} nicht gefunden" }); + + if (send.TransactionType != TransactionType.Send) + return JsonSerializer.Serialize(new { success = false, error = $"Transaktion {sendTransactionId} ist kein Send (ist {send.TransactionType})" }); + + if (receive.TransactionType != TransactionType.Receive) + return JsonSerializer.Serialize(new { success = false, error = $"Transaktion {receiveTransactionId} ist kein Receive (ist {receive.TransactionType})" }); + + if (send.OppositeTransactionId != null) + return JsonSerializer.Serialize(new { success = false, error = $"Send-Transaktion {sendTransactionId} ist bereits verknüpft mit {send.OppositeTransactionId}" }); + + if (receive.OppositeTransactionId != null) + return JsonSerializer.Serialize(new { success = false, error = $"Receive-Transaktion {receiveTransactionId} ist bereits verknüpft mit {receive.OppositeTransactionId}" }); + + if (send.Symbol != receive.Symbol) + return JsonSerializer.Serialize(new { success = false, error = $"Symbol-Mismatch: Send={send.Symbol}, Receive={receive.Symbol}" }); + + // Verknüpfung erstellen + send.OppositeTransactionId = receive.Id; + send.OppositeWalletId = receive.WalletId; + receive.OppositeTransactionId = send.Id; + receive.OppositeWalletId = send.WalletId; + + // Metadaten für Send speichern + var sendMetadata = new TransactionLinkMetadata + { + TransactionId = send.Id, + LinkType = (TransactionLinkType)linkType, + Confidence = Math.Clamp(confidence, 0m, 1m), + Reason = reason, + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = false + }; + _dbContext.TransactionLinkMetadata.Add(sendMetadata); + + // Metadaten für Receive speichern + var receiveMetadata = new TransactionLinkMetadata + { + TransactionId = receive.Id, + LinkType = (TransactionLinkType)linkType, + Confidence = Math.Clamp(confidence, 0m, 1m), + Reason = reason, + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = false + }; + _dbContext.TransactionLinkMetadata.Add(receiveMetadata); + + await _dbContext.SaveChangesAsync(); + + return JsonSerializer.Serialize(new + { + success = true, + message = $"Transaktionen verknüpft: Send #{send.Id} ({send.Wallet.Name}) ↔ Receive #{receive.Id} ({receive.Wallet.Name})", + sendId = send.Id, + receiveId = receive.Id, + symbol = send.Symbol, + sendAmount = send.QuantityAfterFee, + receiveAmount = receive.Quantity + }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/MarkAsIntentionallyUnlinkedTool.cs b/src/CryptoTracker/Agent/Tools/MarkAsIntentionallyUnlinkedTool.cs new file mode 100644 index 0000000..b1c1a28 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/MarkAsIntentionallyUnlinkedTool.cs @@ -0,0 +1,87 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Markieren einer Transaktion als bewusst unverknüpft +/// +public sealed class MarkAsIntentionallyUnlinkedTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public MarkAsIntentionallyUnlinkedTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => MarkAsIntentionallyUnlinkedAsync; + public string GetToolName() => "mark_intentionally_unlinked"; + public string GetToolDescription() => """ + Markiert eine oder mehrere Transaktionen als bewusst ohne Gegenstück. + Verwende dies für externe Einnahmen wie Staking Rewards, Airdrops, Mining, etc. + + Parameter: + - transactionIds: Komma-separierte Liste von Datenbank-IDs (z.B. "1,2,3") + Das sind die "Id" Felder aus get_unlinked_transactions, NICHT TxHash! + - reason: Begründung warum kein Gegenstück existiert + + Gibt Anzahl der markierten Transaktionen zurück. + """; + + private async Task MarkAsIntentionallyUnlinkedAsync( + [Description("Komma-separierte Liste von Datenbank-IDs, z.B. '1,2,3'")] string transactionIds, + [Description("Begründung (z.B. 'Staking Rewards', 'Airdrop')")] string reason) + { + var ids = transactionIds + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var id) ? id : (int?)null) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + + if (!ids.Any()) + return JsonSerializer.Serialize(new { success = false, error = "Keine gültigen IDs angegeben" }); + + var transactions = await _dbContext.CryptoTransactions + .Where(t => ids.Contains(t.Id)) + .Where(t => t.OppositeTransactionId == null) // Nur unverknüpfte + .ToListAsync(); + + if (!transactions.Any()) + return JsonSerializer.Serialize(new { success = false, error = "Keine passenden unverknüpften Transaktionen gefunden" }); + + var markedIds = new List(); + foreach (var tx in transactions) + { + tx.IsIntentionallyUnlinked = true; + + // Metadaten speichern + var metadata = new TransactionLinkMetadata + { + TransactionId = tx.Id, + LinkType = TransactionLinkType.IntentionallyUnlinked, + Confidence = 1.0m, + Reason = reason, + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = true, // Bewusste Entscheidung = bestätigt + ConfirmedAt = DateTimeOffset.UtcNow + }; + _dbContext.TransactionLinkMetadata.Add(metadata); + markedIds.Add(tx.Id); + } + + await _dbContext.SaveChangesAsync(); + + return JsonSerializer.Serialize(new + { + success = true, + message = $"{markedIds.Count} Transaktion(en) als bewusst unverknüpft markiert", + markedIds, + reason + }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/SaveAgentMemoryTool.cs b/src/CryptoTracker/Agent/Tools/SaveAgentMemoryTool.cs new file mode 100644 index 0000000..7e1cf8c --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/SaveAgentMemoryTool.cs @@ -0,0 +1,96 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Speichern von Agent-Regeln im Gedächtnis +/// +public sealed class SaveAgentMemoryTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public SaveAgentMemoryTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => SaveAgentMemoryAsync; + public string GetToolName() => "save_memory"; + public string GetToolDescription() => """ + Speichert eine Regel im Agent-Gedächtnis für zukünftige Verwendung. + Verwende dies wenn der Benutzer eine wiederkehrende Entscheidung trifft. + + Parameter: + - memoryType: Art des Eintrags: + "SkipPattern" = Kommentar-Muster zum Überspringen (z.B. "Staking Rewards") + "WalletMapping" = Wallet-Zuordnung (z.B. "PC-Wallet" → interner Transfer) + "AddressMapping" = Adress-Zuordnung + "UserDecision" = Allgemeine Benutzer-Entscheidung + "ImportErrorPattern" = Erkanntes Importfehler-Muster + - key: Schlüssel (z.B. das Kommentar-Pattern, die Adresse) + - value: Wert (z.B. "skip", WalletId, Beschreibung) + - description: Menschenlesbare Beschreibung + + Gibt Erfolg/Fehler zurück. + """; + + private async Task SaveAgentMemoryAsync( + [Description("Art: SkipPattern, WalletMapping, AddressMapping, UserDecision, ImportErrorPattern")] string memoryType, + [Description("Schlüssel (z.B. Kommentar-Pattern)")] string key, + [Description("Wert (z.B. 'skip', WalletId)")] string value, + [Description("Menschenlesbare Beschreibung")] string description) + { + if (!Enum.TryParse(memoryType, true, out var type)) + return JsonSerializer.Serialize(new { success = false, error = $"Ungültiger memoryType: {memoryType}" }); + + if (string.IsNullOrWhiteSpace(key)) + return JsonSerializer.Serialize(new { success = false, error = "Key darf nicht leer sein" }); + + const string agentKey = "transaction-linking"; + + // Prüfen ob bereits existiert + var existing = await _dbContext.AgentMemories + .FirstOrDefaultAsync(m => m.AgentKey == agentKey && m.MemoryType == type && m.Key == key); + + if (existing != null) + { + // Update + existing.Value = value; + existing.Description = description; + existing.UsageCount++; + } + else + { + // Insert + var memory = new AgentMemory + { + AgentKey = agentKey, + MemoryType = type, + Key = key, + Value = value, + Description = description, + CreatedAt = DateTimeOffset.UtcNow, + UsageCount = 0 + }; + _dbContext.AgentMemories.Add(memory); + } + + await _dbContext.SaveChangesAsync(); + + return JsonSerializer.Serialize(new + { + success = true, + message = existing != null + ? $"Regel aktualisiert: {type} '{key}'" + : $"Neue Regel gespeichert: {type} '{key}'", + memoryType = type.ToString(), + key, + value, + isUpdate = existing != null + }); + } +} diff --git a/src/CryptoTracker/Components/Layout/NavMenu.razor b/src/CryptoTracker/Components/Layout/NavMenu.razor index c242c64..d6919a3 100644 --- a/src/CryptoTracker/Components/Layout/NavMenu.razor +++ b/src/CryptoTracker/Components/Layout/NavMenu.razor @@ -5,6 +5,8 @@ + + diff --git a/src/CryptoTracker/Controllers/DataImportController.cs b/src/CryptoTracker/Controllers/DataImportController.cs index 68f1723..1fce730 100644 --- a/src/CryptoTracker/Controllers/DataImportController.cs +++ b/src/CryptoTracker/Controllers/DataImportController.cs @@ -62,12 +62,13 @@ public async Task PreviewImport([FromForm] IFormFile file) return Ok(result); } - [HttpPost("[action]")] - public async Task ProcessTransactionPairs() - { - await _dataImportService.ProcessTransactionPairs(); - return Ok("Transaktionen wurden erfolgreich zusammengeführt"); - } + //Obsolete durch neue Linking Logic + //[HttpPost("[action]")] + //public async Task ProcessTransactionPairs() + //{ + // await _dataImportService.ProcessTransactionPairs(); + // return Ok("Transaktionen wurden erfolgreich zusammengeführt"); + //} private const long MAX_REQUEST_SIZE = 1024 * 1024 * 100; @@ -92,7 +93,8 @@ async Task IDataImportApi.ImportAutoAsync(string walletName, IBrowserFile file, await _importAutoService.ImportAsync(walletName, () => new MemoryStream(memory.ToArray()), file.Name, documentType); } - Task IDataImportApi.ProcessTransactionPairsAsync() - => _dataImportService.ProcessTransactionPairs(); + //Obsolete durch neue Linking Logic + //Task IDataImportApi.ProcessTransactionPairsAsync() + // => _dataImportService.ProcessTransactionPairs(); } } diff --git a/src/CryptoTracker/Controllers/TransactionLinkingController.cs b/src/CryptoTracker/Controllers/TransactionLinkingController.cs new file mode 100644 index 0000000..fcf0005 --- /dev/null +++ b/src/CryptoTracker/Controllers/TransactionLinkingController.cs @@ -0,0 +1,286 @@ +using CryptoTracker.Agent.Services; +using CryptoTracker.Entities; +using CryptoTracker.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace CryptoTracker.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class TransactionLinkingController : ControllerBase, ITransactionLinkingApi +{ + private readonly TransactionLinkingService _linkingService; + private readonly CryptoTrackerDbContext _dbContext; + + public TransactionLinkingController( + TransactionLinkingService linkingService, + CryptoTrackerDbContext dbContext) + { + _linkingService = linkingService; + _dbContext = dbContext; + } + + /// + /// Prüft ob der AI-Service konfiguriert ist + /// + [HttpGet("status")] + public async Task GetStatusAsync() + { + await Task.CompletedTask; + return new ServiceStatusDTO + { + IsConfigured = _linkingService.IsConfigured, + Message = _linkingService.IsConfigured + ? "AI-Service ist bereit" + : "AI-Service nicht konfiguriert. Bitte OpenAI-Einstellungen prüfen." + }; + } + + /// + /// Gibt Statistiken über Verknüpfungen zurück + /// + [HttpGet("statistics")] + public async Task GetStatisticsAsync() + { + var stats = await _linkingService.GetStatisticsAsync(); + return new LinkingStatisticsDTO + { + TotalTransactions = stats.TotalTransactions, + LinkedTransactions = stats.LinkedTransactions, + IntentionallyUnlinked = stats.IntentionallyUnlinked, + UnlinkedTransactions = stats.UnlinkedTransactions, + ConfirmedLinks = stats.ConfirmedLinks, + UnconfirmedLinks = stats.UnconfirmedLinks, + UnlinkedBySymbol = stats.UnlinkedBySymbol + }; + } + + /// + /// Startet eine neue Agent-Session + /// + [HttpPost("session/start")] + public async Task StartSessionAsync() + { + var result = await _linkingService.StartSessionAsync(); + return new LinkingSessionResultDTO + { + AgentResponse = result.AgentResponse, + Success = result.Success, + Proposals = result.Proposals?.Select(p => new TransactionLinkProposalDTO + { + SendId = p.SendId, + ReceiveId = p.ReceiveId, + Confidence = p.Confidence, + Reason = p.Reason + }).ToList() + }; + } + + /// + /// Sendet eine Nachricht an den Agent + /// + [HttpPost("session/message")] + public async Task SendMessageAsync([FromBody] string message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return new LinkingSessionResultDTO + { + AgentResponse = "Nachricht darf nicht leer sein", + Success = false + }; + } + + var result = await _linkingService.ContinueSessionAsync(message); + return new LinkingSessionResultDTO + { + AgentResponse = result.AgentResponse, + Success = result.Success, + Proposals = result.Proposals?.Select(p => new TransactionLinkProposalDTO + { + SendId = p.SendId, + ReceiveId = p.ReceiveId, + Confidence = p.Confidence, + Reason = p.Reason + }).ToList() + }; + } + + /// + /// Führt automatische Verknüpfung durch + /// + [HttpPost("auto-link")] + public async Task RunAutoLinkAsync() + { + var result = await _linkingService.RunAutomaticLinkingAsync(); + return new AutoLinkResultDTO + { + LinkedCount = result.LinkedCount, + MarkedUnlinkedCount = result.MarkedUnlinkedCount, + RemainingUnlinkedCount = result.RemainingUnlinkedCount, + Summary = result.Summary, + Success = result.Success + }; + } + + /// + /// Gibt alle unverknüpften Transaktionen zurück + /// + [HttpGet("unlinked")] + public async Task> GetUnlinkedAsync( + [FromQuery] string? type = null, + [FromQuery] string? symbol = null, + [FromQuery] int limit = 100, + [FromQuery] int offset = 0) + { + var query = _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked); + + if (type?.Equals("send", StringComparison.OrdinalIgnoreCase) == true) + query = query.Where(t => t.TransactionType == TransactionType.Send); + else if (type?.Equals("receive", StringComparison.OrdinalIgnoreCase) == true) + query = query.Where(t => t.TransactionType == TransactionType.Receive); + + if (!string.IsNullOrEmpty(symbol)) + query = query.Where(t => t.Symbol == symbol); + + var transactions = await query + .OrderByDescending(t => t.DateTime) + .Skip(offset) + .Take(limit) + .Select(t => new UnlinkedTransactionDTO + { + Id = t.Id, + DateTime = t.DateTime, + Type = t.TransactionType.ToString(), + Symbol = t.Symbol, + Quantity = t.Quantity, + QuantityAfterFee = t.QuantityAfterFee, + Comment = t.Comment, + Address = t.Address, + WalletName = t.Wallet.Name, + TransactionId = t.TransactionId, + Network = t.Network + }) + .ToListAsync(); + + return transactions; + } + + /// + /// Manuell zwei Transaktionen verknüpfen + /// + [HttpPost("link")] + public async Task ManualLinkAsync([FromQuery] int sendId, [FromQuery] int receiveId, [FromQuery] string? reason = null) + { + var send = await _dbContext.CryptoTransactions.FindAsync(sendId); + var receive = await _dbContext.CryptoTransactions.FindAsync(receiveId); + + if (send == null) + return new LinkResultDTO { Success = false, Message = $"Send-Transaktion {sendId} nicht gefunden" }; + if (receive == null) + return new LinkResultDTO { Success = false, Message = $"Receive-Transaktion {receiveId} nicht gefunden" }; + + if (send.TransactionType != TransactionType.Send) + return new LinkResultDTO { Success = false, Message = $"Transaktion {sendId} ist kein Send" }; + if (receive.TransactionType != TransactionType.Receive) + return new LinkResultDTO { Success = false, Message = $"Transaktion {receiveId} ist kein Receive" }; + + if (send.OppositeTransactionId != null) + return new LinkResultDTO { Success = false, Message = "Send-Transaktion ist bereits verknüpft" }; + if (receive.OppositeTransactionId != null) + return new LinkResultDTO { Success = false, Message = "Receive-Transaktion ist bereits verknüpft" }; + + send.OppositeTransactionId = receive.Id; + send.OppositeWalletId = receive.WalletId; + receive.OppositeTransactionId = send.Id; + receive.OppositeWalletId = send.WalletId; + + var metadata = new TransactionLinkMetadata + { + TransactionId = send.Id, + LinkType = TransactionLinkType.Manual, + Confidence = 1.0m, + Reason = reason ?? "Manuell verknüpft", + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = true, + ConfirmedAt = DateTimeOffset.UtcNow + }; + _dbContext.TransactionLinkMetadata.Add(metadata); + + await _dbContext.SaveChangesAsync(); + return new LinkResultDTO { Success = true, Message = "Transaktionen verknüpft" }; + } + + /// + /// Bestätigt vorgeschlagene Verknüpfungen + /// + [HttpPost("confirm")] + public async Task ConfirmLinksAsync([FromBody] IList transactionIds) + { + var metadataList = await _dbContext.TransactionLinkMetadata + .Where(m => transactionIds.Contains(m.TransactionId)) + .ToListAsync(); + + foreach (var metadata in metadataList) + { + metadata.IsConfirmed = true; + metadata.ConfirmedAt = DateTimeOffset.UtcNow; + } + + await _dbContext.SaveChangesAsync(); + return new ConfirmResultDTO { ConfirmedCount = metadataList.Count }; + } + + /// + /// Setzt alle Verknüpfungen zurück + /// + [HttpPost("reset")] + public async Task ResetLinksAsync( + [FromQuery] bool keepManualLinks = true, + [FromQuery] bool resetIntentionallyUnlinked = false) + { + var result = await _linkingService.ResetAllLinksAsync( + keepManualLinks, + resetIntentionallyUnlinked); + + return new ResetResultDTO + { + ResetCount = result.ResetCount, + MetadataDeleted = result.MetadataDeleted + }; + } + + /// + /// Markiert eine Transaktion als absichtlich unverknüpft (externe Einnahme/Ausgabe) + /// + [HttpPost("mark-unlinked")] + public async Task MarkAsIntentionallyUnlinkedAsync([FromQuery] int transactionId, [FromQuery] string reason) + { + var transaction = await _dbContext.CryptoTransactions.FindAsync(transactionId); + if (transaction == null) + return new LinkResultDTO { Success = false, Message = $"Transaktion {transactionId} nicht gefunden" }; + + if (transaction.OppositeTransactionId != null) + return new LinkResultDTO { Success = false, Message = "Transaktion ist bereits verknüpft" }; + + transaction.IsIntentionallyUnlinked = true; + + var metadata = new TransactionLinkMetadata + { + TransactionId = transactionId, + LinkType = TransactionLinkType.IntentionallyUnlinked | TransactionLinkType.Manual, + Confidence = 1.0m, + Reason = reason, + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = true, + ConfirmedAt = DateTimeOffset.UtcNow + }; + _dbContext.TransactionLinkMetadata.Add(metadata); + + await _dbContext.SaveChangesAsync(); + return new LinkResultDTO { Success = true, Message = "Transaktion als externe Einnahme/Ausgabe markiert" }; + } +} diff --git a/src/CryptoTracker/CryptoTracker.csproj b/src/CryptoTracker/CryptoTracker.csproj index 4416e87..e7e1ccf 100644 --- a/src/CryptoTracker/CryptoTracker.csproj +++ b/src/CryptoTracker/CryptoTracker.csproj @@ -5,9 +5,11 @@ enable enable ec7422a4-c6cc-42a0-b4d0-1556b863174c + + $(NoWarn);NU1603 - + @@ -25,5 +27,18 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs b/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs index c9df193..8c3e9f9 100644 --- a/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs +++ b/src/CryptoTracker/DbContext/CryptoTrackerDbContext.cs @@ -22,6 +22,8 @@ public class CryptoTrackerDbContext : DbContext public DbSet ManualCoinPrices { get; set; } public DbSet AssetLots { get; set; } public DbSet LotMovements { get; set; } + public DbSet TransactionLinkMetadata { get; set; } + public DbSet AgentMemories { get; set; } public CryptoTrackerDbContext(DbContextOptions options) : base(options) { @@ -207,6 +209,29 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(t => t.SourceLotId) .OnDelete(DeleteBehavior.Restrict); + // === TransactionLinkMetadata Configuration === + modelBuilder.Entity().HasKey(m => m.Id); + modelBuilder.Entity() + .HasOne(m => m.Transaction) + .WithOne(t => t.LinkMetadata) + .HasForeignKey(m => m.TransactionId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .Property(m => m.Confidence) + .HasColumnType("decimal(5, 4)"); + modelBuilder.Entity() + .HasIndex(m => m.LinkType); + modelBuilder.Entity() + .HasIndex(m => m.IsConfirmed); + + // === AgentMemory Configuration === + modelBuilder.Entity().HasKey(m => m.Id); + modelBuilder.Entity() + .HasIndex(m => new { m.AgentKey, m.MemoryType, m.Key }) + .IsUnique(); + modelBuilder.Entity() + .HasIndex(m => m.AgentKey); + base.OnModelCreating(modelBuilder); } } diff --git a/src/CryptoTracker/Entities/AgentMemory.cs b/src/CryptoTracker/Entities/AgentMemory.cs new file mode 100644 index 0000000..6cf283d --- /dev/null +++ b/src/CryptoTracker/Entities/AgentMemory.cs @@ -0,0 +1,79 @@ +namespace CryptoTracker.Entities; + +/// +/// Typ des Agent-Gedächtniseintrags +/// +public enum AgentMemoryType +{ + /// + /// Kommentar-Muster zum Überspringen (z.B. "ETH 2.0 Staking Rewards" = externe Einnahme) + /// + SkipPattern, + + /// + /// Bekannte Wallet-Zuordnung (z.B. "PC-Wallet Thomas PC" → Interner Transfer) + /// + WalletMapping, + + /// + /// Bekannte Adress-Zuordnung (z.B. bestimmte Adresse gehört zu eigenem Wallet) + /// + AddressMapping, + + /// + /// Benutzer-Entscheidung für ähnliche Fälle + /// + UserDecision, + + /// + /// Erkanntes Importfehler-Muster (z.B. UTC vs. Lokalzeit) + /// + ImportErrorPattern +} + +/// +/// Persistentes Gedächtnis für den Linking-Agent. +/// Speichert gelernte Regeln und Benutzerentscheidungen. +/// +public class AgentMemory +{ + public int Id { get; set; } + + /// + /// Agent-Schlüssel (z.B. "transaction-linking", "lot-linking") + /// + public string AgentKey { get; set; } = string.Empty; + + /// + /// Typ des Eintrags + /// + public AgentMemoryType MemoryType { get; set; } + + /// + /// Schlüssel für den Eintrag (z.B. Kommentar-Pattern, Adresse, Wallet-Name) + /// + public string Key { get; set; } = string.Empty; + + /// + /// Wert/Payload (JSON oder einfacher String) + /// Bei SkipPattern: "true" oder Begründung + /// Bei WalletMapping: WalletId oder Wallet-Name + /// + public string Value { get; set; } = string.Empty; + + /// + /// Beschreibung/Begründung (menschenlesbar) + /// + public string? Description { get; set; } + + /// + /// Erstellt am + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Wie oft wurde diese Regel angewendet? + /// Hilft bei der Priorisierung und Aufräumen ungenutzter Regeln. + /// + public int UsageCount { get; set; } +} diff --git a/src/CryptoTracker/Entities/CryptoTransaction.cs b/src/CryptoTracker/Entities/CryptoTransaction.cs index 5123b99..9f55fd1 100644 --- a/src/CryptoTracker/Entities/CryptoTransaction.cs +++ b/src/CryptoTracker/Entities/CryptoTransaction.cs @@ -54,6 +54,19 @@ public class CryptoTransaction : IFlow public string? Comment { get; set; } public bool IsHidden { get; set; } + // === Link-Tracking === + + /// + /// Wurde diese Transaktion bewusst ohne Gegenstück gelassen? + /// Z.B. bei Staking Rewards, Airdrops, Mining-Einnahmen. + /// + public bool IsIntentionallyUnlinked { get; set; } + + /// + /// Metadaten zur Verknüpfung (wie/warum verknüpft) + /// + public TransactionLinkMetadata? LinkMetadata { get; set; } + // === Lot-Tracking === /// diff --git a/src/CryptoTracker/Entities/TransactionLinkMetadata.cs b/src/CryptoTracker/Entities/TransactionLinkMetadata.cs new file mode 100644 index 0000000..fb7bec4 --- /dev/null +++ b/src/CryptoTracker/Entities/TransactionLinkMetadata.cs @@ -0,0 +1,49 @@ +namespace CryptoTracker.Entities; + +/// +/// Metadaten zur Transaktionsverknüpfung. +/// Speichert, wie und warum eine Transaktion verknüpft wurde. +/// +public class TransactionLinkMetadata +{ + public int Id { get; set; } + + /// + /// Die verknüpfte Transaktion (Send oder Receive) + /// + public int TransactionId { get; set; } + public CryptoTransaction Transaction { get; set; } = null!; + + /// + /// Art der Verknüpfung (Flags - können kombiniert werden) + /// + public TransactionLinkType LinkType { get; set; } + + /// + /// Konfidenz der Verknüpfung (0.0 - 1.0) + /// 1.0 = Sicher (z.B. manuelle Bestätigung) + /// 0.0-0.5 = Unsicher, Benutzerbestätigung empfohlen + /// + public decimal Confidence { get; set; } + + /// + /// Begründung für die Verknüpfung (vom LLM oder System generiert) + /// + public string? Reason { get; set; } + + /// + /// Wurde vom Benutzer bestätigt? + /// Unbestätigte Verknüpfungen können später überprüft werden. + /// + public bool IsConfirmed { get; set; } + + /// + /// Zeitpunkt der Verknüpfung + /// + public DateTimeOffset LinkedAt { get; set; } + + /// + /// Zeitpunkt der Benutzerbestätigung (falls bestätigt) + /// + public DateTimeOffset? ConfirmedAt { get; set; } +} diff --git a/src/CryptoTracker/Entities/TransactionLinkType.cs b/src/CryptoTracker/Entities/TransactionLinkType.cs new file mode 100644 index 0000000..0cb4528 --- /dev/null +++ b/src/CryptoTracker/Entities/TransactionLinkType.cs @@ -0,0 +1,46 @@ +namespace CryptoTracker.Entities; + +/// +/// Flags für die Art der Transaktionsverknüpfung. +/// Mehrere Typen können kombiniert werden (z.B. TimeAndAmount | AIAssisted). +/// +[Flags] +public enum TransactionLinkType +{ + None = 0, + + /// + /// Exakte Zeit- und Betrags-Übereinstimmung (alte ProcessTransactionPairs-Logik) + /// + TimeAndAmount = 1, + + /// + /// Via LLM/KI-Analyse verknüpft + /// + AIAssisted = 2, + + /// + /// Direkte Verknüpfung (gleiche TxId, Adresse) + /// + Direct = 4, + + /// + /// Indirekte Verknüpfung (Kommentar-Analyse, Wallet-Name) + /// + Indirect = 8, + + /// + /// Automatisch vom System verknüpft (ohne Benutzerinteraktion) + /// + Automatic = 16, + + /// + /// Manuell vom Benutzer verknüpft + /// + Manual = 32, + + /// + /// Bewusst ohne Gegenstück gelassen (z.B. Staking Rewards, Airdrops) + /// + IntentionallyUnlinked = 64 +} diff --git a/src/CryptoTracker/Import/BitpandaTransactionImporter.cs b/src/CryptoTracker/Import/BitpandaTransactionImporter.cs index d1a683a..4aff3fc 100644 --- a/src/CryptoTracker/Import/BitpandaTransactionImporter.cs +++ b/src/CryptoTracker/Import/BitpandaTransactionImporter.cs @@ -1,4 +1,4 @@ -using CryptoTracker.Entities; +using CryptoTracker.Entities; using CryptoTracker.Import.Objects; using CsvHelper; using CsvHelper.Configuration; @@ -74,16 +74,18 @@ protected override async Task OnImport(ImportArgs args, IEnumerable +using System; +using CryptoTracker; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CryptoTracker.Migrations +{ + [DbContext(typeof(CryptoTrackerDbContext))] + [Migration("20260131133250_AddAILinkingEntities")] + partial class AddAILinkingEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CryptoTracker.Entities.AgentMemory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AgentKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("MemoryType") + .HasColumnType("int"); + + b.Property("UsageCount") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AgentKey"); + + b.HasIndex("AgentKey", "MemoryType", "Key") + .IsUnique(); + + b.ToTable("AgentMemories"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcquisitionDate") + .HasColumnType("datetimeoffset"); + + b.Property("AcquisitionPriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("AcquisitionType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CurrentWalletId") + .HasColumnType("int"); + + b.Property("FlowIncompleteReason") + .HasColumnType("nvarchar(max)"); + + b.Property("IsFlowComplete") + .HasColumnType("bit"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalQuantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("ParentLotId") + .HasColumnType("int"); + + b.Property("RemainingQuantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("SourceTradeId") + .HasColumnType("int"); + + b.Property("SourceTransactionId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TotalAcquisitionCostEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("TransformedToLotId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AcquisitionDate"); + + b.HasIndex("ParentLotId"); + + b.HasIndex("SourceTradeId"); + + b.HasIndex("SourceTransactionId"); + + b.HasIndex("TransformedToLotId"); + + b.HasIndex("CurrentWalletId", "Symbol"); + + b.ToTable("AssetLots"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Comment") + .HasColumnType("nvarchar(max)"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("Fee") + .HasColumnType("decimal(27, 12)"); + + b.Property("ForeignFee") + .HasColumnType("decimal(27, 12)"); + + b.Property("ForeignFeeSymbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + + b.Property("OppositeSymbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OppositeTradeId") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("decimal(27, 12)"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("Referenz") + .HasColumnType("nvarchar(max)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("SourceLotId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TradeType") + .HasColumnType("int"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OppositeTradeId") + .IsUnique() + .HasFilter("[OppositeTradeId] IS NOT NULL"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("SourceLotId"); + + b.HasIndex("WalletId"); + + b.ToTable("CryptoTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasColumnType("nvarchar(max)"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("Fee") + .HasColumnType("decimal(27, 12)"); + + b.Property("IsIntentionallyUnlinked") + .HasColumnType("bit"); + + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + + b.Property("Network") + .HasColumnType("nvarchar(max)"); + + b.Property("OppositeTransactionId") + .HasColumnType("int"); + + b.Property("OppositeWalletId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionId") + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .HasColumnType("int"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OppositeTransactionId") + .IsUnique() + .HasFilter("[OppositeTransactionId] IS NOT NULL"); + + b.HasIndex("OppositeWalletId"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("WalletId"); + + b.ToTable("CryptoTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TXID") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BinanceDeposits"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Executed") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fee") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pair") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.Property("Side") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BinanceTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TXID") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BinanceWithdrawals"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Adresse") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CryptoNachGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("CryptoVorGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("Datum") + .HasColumnType("datetimeoffset"); + + b.Property("EinheitKurs") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EinheitMengeNachGebuehr") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EinheitMengeVorGebuehr") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Kommentar") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Kontostand") + .HasColumnType("decimal(18,2)"); + + b.Property("Kurs") + .HasColumnType("decimal(18,2)"); + + b.Property("MengeNachGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("MengeVorGebuehr") + .HasColumnType("decimal(18,2)"); + + b.Property("Referenz") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Typ") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Waehrung") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.Property("ZuAbgang") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BitcoinDeTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AmountAsset") + .HasColumnType("decimal(18,2)"); + + b.Property("AmountFiat") + .HasColumnType("decimal(18,2)"); + + b.Property("Asset") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AssetClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AssetMarketPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("AssetMarketPriceCurrency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fee") + .HasColumnType("decimal(18,2)"); + + b.Property("FeeAsset") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fiat") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InOut") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProductID") + .HasColumnType("int"); + + b.Property("Spread") + .HasColumnType("decimal(18,2)"); + + b.Property("SpreadCurrency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TaxFiat") + .HasColumnType("decimal(18,2)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("TransactionId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("BitpandaTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Datum") + .HasColumnType("datetimeoffset"); + + b.Property("Kommentar") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("Typ") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("LedgerTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Executed") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fee") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pair") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Side") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Tradingplatform") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("MetamaskTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Datum") + .HasColumnType("datetimeoffset"); + + b.Property("Kommentar") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("Typ") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("MetamaskTransactions"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Coin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Network") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TXID") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionFee") + .HasColumnType("decimal(18,2)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("OkxDeposits"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Executed") + .HasColumnType("decimal(18,2)"); + + b.Property("Pair") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Side") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WalletId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("OkxTrades"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DateTime") + .HasColumnType("datetimeoffset"); + + b.Property("IsTaxFree") + .HasColumnType("bit"); + + b.Property("LotId") + .HasColumnType("int"); + + b.Property("MovementType") + .HasColumnType("int"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("Quantity") + .HasColumnType("decimal(27, 12)"); + + b.Property("RealizedGainEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("SalePriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("TaxFreeReason") + .HasColumnType("int"); + + b.Property("TradeId") + .HasColumnType("int"); + + b.Property("TransactionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DateTime"); + + b.HasIndex("LotId"); + + b.HasIndex("ResultingLotId"); + + b.HasIndex("TradeId"); + + b.HasIndex("TransactionId"); + + b.ToTable("LotMovements"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.ManualCoinPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("PriceEur") + .HasColumnType("decimal(27, 12)"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Symbol", "Date") + .IsUnique(); + + b.ToTable("ManualCoinPrices"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.TransactionLinkMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Confidence") + .HasColumnType("decimal(5, 4)"); + + b.Property("ConfirmedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsConfirmed") + .HasColumnType("bit"); + + b.Property("LinkType") + .HasColumnType("int"); + + b.Property("LinkedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IsConfirmed"); + + b.HasIndex("LinkType"); + + b.HasIndex("TransactionId") + .IsUnique(); + + b.ToTable("TransactionLinkMetadata"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "CurrentWallet") + .WithMany() + .HasForeignKey("CurrentWalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ParentLot") + .WithMany("ChildLots") + .HasForeignKey("ParentLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTrade", "SourceTrade") + .WithMany() + .HasForeignKey("SourceTradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "SourceTransaction") + .WithMany() + .HasForeignKey("SourceTransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "TransformedToLot") + .WithMany("TransformedFromLots") + .HasForeignKey("TransformedToLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CurrentWallet"); + + b.Navigation("ParentLot"); + + b.Navigation("SourceTrade"); + + b.Navigation("SourceTransaction"); + + b.Navigation("TransformedToLot"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.HasOne("CryptoTracker.Entities.CryptoTrade", "OppositeTrade") + .WithOne() + .HasForeignKey("CryptoTracker.Entities.CryptoTrade", "OppositeTradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "SourceLot") + .WithMany() + .HasForeignKey("SourceLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OppositeTrade"); + + b.Navigation("ResultingLot"); + + b.Navigation("SourceLot"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "OppositeTransaction") + .WithOne() + .HasForeignKey("CryptoTracker.Entities.CryptoTransaction", "OppositeTransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.Wallet", "OppositeWallet") + .WithMany() + .HasForeignKey("OppositeWalletId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OppositeTransaction"); + + b.Navigation("OppositeWallet"); + + b.Navigation("ResultingLot"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => + { + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => + { + b.HasOne("CryptoTracker.Entities.AssetLot", "Lot") + .WithMany("Movements") + .HasForeignKey("LotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") + .WithMany() + .HasForeignKey("ResultingLotId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTrade", "Trade") + .WithMany("LotMovements") + .HasForeignKey("TradeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "Transaction") + .WithMany("LotMovements") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Lot"); + + b.Navigation("ResultingLot"); + + b.Navigation("Trade"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.TransactionLinkMetadata", b => + { + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "Transaction") + .WithOne("LinkMetadata") + .HasForeignKey("CryptoTracker.Entities.TransactionLinkMetadata", "TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => + { + b.Navigation("ChildLots"); + + b.Navigation("Movements"); + + b.Navigation("TransformedFromLots"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => + { + b.Navigation("LotMovements"); + }); + + modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => + { + b.Navigation("LinkMetadata"); + + b.Navigation("LotMovements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.cs b/src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.cs new file mode 100644 index 0000000..654f475 --- /dev/null +++ b/src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.cs @@ -0,0 +1,107 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CryptoTracker.Migrations +{ + /// + public partial class AddAILinkingEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsIntentionallyUnlinked", + table: "CryptoTransactions", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "AgentMemories", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + AgentKey = table.Column(type: "nvarchar(450)", nullable: false), + MemoryType = table.Column(type: "int", nullable: false), + Key = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + UsageCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AgentMemories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TransactionLinkMetadata", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TransactionId = table.Column(type: "int", nullable: false), + LinkType = table.Column(type: "int", nullable: false), + Confidence = table.Column(type: "decimal(5,4)", nullable: false), + Reason = table.Column(type: "nvarchar(max)", nullable: true), + IsConfirmed = table.Column(type: "bit", nullable: false), + LinkedAt = table.Column(type: "datetimeoffset", nullable: false), + ConfirmedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TransactionLinkMetadata", x => x.Id); + table.ForeignKey( + name: "FK_TransactionLinkMetadata_CryptoTransactions_TransactionId", + column: x => x.TransactionId, + principalTable: "CryptoTransactions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AgentMemories_AgentKey", + table: "AgentMemories", + column: "AgentKey"); + + migrationBuilder.CreateIndex( + name: "IX_AgentMemories_AgentKey_MemoryType_Key", + table: "AgentMemories", + columns: new[] { "AgentKey", "MemoryType", "Key" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLinkMetadata_IsConfirmed", + table: "TransactionLinkMetadata", + column: "IsConfirmed"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLinkMetadata_LinkType", + table: "TransactionLinkMetadata", + column: "LinkType"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLinkMetadata_TransactionId", + table: "TransactionLinkMetadata", + column: "TransactionId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AgentMemories"); + + migrationBuilder.DropTable( + name: "TransactionLinkMetadata"); + + migrationBuilder.DropColumn( + name: "IsIntentionallyUnlinked", + table: "CryptoTransactions"); + } + } +} diff --git a/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs b/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs index 130cb81..9e7d16c 100644 --- a/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs +++ b/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs @@ -22,6 +22,48 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("CryptoTracker.Entities.AgentMemory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AgentKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("MemoryType") + .HasColumnType("int"); + + b.Property("UsageCount") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AgentKey"); + + b.HasIndex("AgentKey", "MemoryType", "Key") + .IsUnique(); + + b.ToTable("AgentMemories"); + }); + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => { b.Property("Id") @@ -193,6 +235,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Fee") .HasColumnType("decimal(27, 12)"); + b.Property("IsHidden") + .HasColumnType("bit"); + + b.Property("IsIntentionallyUnlinked") + .HasColumnType("bit"); + + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + b.Property("Network") .HasColumnType("nvarchar(max)"); @@ -847,6 +898,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ManualCoinPrices"); }); + modelBuilder.Entity("CryptoTracker.Entities.TransactionLinkMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Confidence") + .HasColumnType("decimal(5, 4)"); + + b.Property("ConfirmedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsConfirmed") + .HasColumnType("bit"); + + b.Property("LinkType") + .HasColumnType("int"); + + b.Property("LinkedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IsConfirmed"); + + b.HasIndex("LinkType"); + + b.HasIndex("TransactionId") + .IsUnique(); + + b.ToTable("TransactionLinkMetadata"); + }); + modelBuilder.Entity("CryptoTracker.Entities.Wallet", b => { b.Property("Id") @@ -1112,6 +1204,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Transaction"); }); + modelBuilder.Entity("CryptoTracker.Entities.TransactionLinkMetadata", b => + { + b.HasOne("CryptoTracker.Entities.CryptoTransaction", "Transaction") + .WithOne("LinkMetadata") + .HasForeignKey("CryptoTracker.Entities.TransactionLinkMetadata", "TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => { b.Navigation("ChildLots"); @@ -1128,6 +1231,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => { + b.Navigation("LinkMetadata"); + b.Navigation("LotMovements"); }); #pragma warning restore 612, 618 diff --git a/src/CryptoTracker/Services/DataImportService.cs b/src/CryptoTracker/Services/DataImportService.cs index 9e35a41..18f5ee0 100644 --- a/src/CryptoTracker/Services/DataImportService.cs +++ b/src/CryptoTracker/Services/DataImportService.cs @@ -35,7 +35,8 @@ public async Task Import(ImportDocumentType type, string walletName, Func openStreamFunc, Wallet wallet) @@ -152,34 +153,35 @@ private class BitpandaIntConverter : CsvHelper.TypeConversion.Int32Converter } } - public async Task ProcessTransactionPairs() - { - var transactions = await _dbContext.CryptoTransactions.ToListAsync(); - - foreach (var transaction in transactions.Where(t => t.TransactionType == TransactionType.Send && t.OppositeTransactionId == null)) - { - var oppositeTransaction = transactions - .Where(t => t.TransactionType == TransactionType.Receive && - t.OppositeTransactionId == null && - t.Symbol == transaction.Symbol && - t.QuantityAfterFee == transaction.QuantityAfterFee && - t.DateTime >= transaction.DateTime.AddMinutes(-5) && - t.DateTime <= transaction.DateTime.AddMinutes(5)) - .FirstOrDefault(); - - if (oppositeTransaction != null) - { - transaction.OppositeTransaction = oppositeTransaction; - transaction.OppositeWallet = oppositeTransaction.Wallet; - transaction.OppositeWalletId = oppositeTransaction.WalletId; - oppositeTransaction.OppositeTransaction = transaction; - oppositeTransaction.OppositeWallet = transaction.Wallet; - oppositeTransaction.OppositeWalletId = transaction.WalletId; - } - } - - await _dbContext.SaveChangesAsync(); - } + //Obsolete durch neue Linking Logic + //public async Task ProcessTransactionPairs() + //{ + // var transactions = await _dbContext.CryptoTransactions.ToListAsync(); + + // foreach (var transaction in transactions.Where(t => t.TransactionType == TransactionType.Send && t.OppositeTransactionId == null)) + // { + // var oppositeTransaction = transactions + // .Where(t => t.TransactionType == TransactionType.Receive && + // t.OppositeTransactionId == null && + // t.Symbol == transaction.Symbol && + // t.QuantityAfterFee == transaction.QuantityAfterFee && + // t.DateTime >= transaction.DateTime.AddMinutes(-5) && + // t.DateTime <= transaction.DateTime.AddMinutes(5)) + // .FirstOrDefault(); + + // if (oppositeTransaction != null) + // { + // transaction.OppositeTransaction = oppositeTransaction; + // transaction.OppositeWallet = oppositeTransaction.Wallet; + // transaction.OppositeWalletId = oppositeTransaction.WalletId; + // oppositeTransaction.OppositeTransaction = transaction; + // oppositeTransaction.OppositeWallet = transaction.Wallet; + // oppositeTransaction.OppositeWalletId = transaction.WalletId; + // } + // } + + // await _dbContext.SaveChangesAsync(); + //} private IImporter GetImporter(ImportDocumentType type) => type switch diff --git a/src/CryptoTracker/Services/FinanceValueProvider.cs b/src/CryptoTracker/Services/FinanceValueProvider.cs index 339634d..61986ba 100644 --- a/src/CryptoTracker/Services/FinanceValueProvider.cs +++ b/src/CryptoTracker/Services/FinanceValueProvider.cs @@ -20,6 +20,9 @@ public FinanceValueProvider(ICoinmarketcapClient client, IMemoryCache cache, ILo public async Task GetCurrentEuroValueAsync(string symbol) { + if (string.IsNullOrWhiteSpace(symbol)) + return 0; + if (_cache.TryGetValue(symbol, out decimal cached)) { return cached; @@ -31,11 +34,25 @@ public async Task GetCurrentEuroValueAsync(string symbol) var response = _client.GetCurrencyBySymbol(symbol, "EUR"); price = response.Price; } + catch (JsonSerializationException ex) + { + // Known issue with NoobsMuc.Coinmarketcap.Client: nullable int fields + // (like num_market_pairs) cause deserialization errors + _logger.LogDebug(ex, "JSON deserialization error for {Symbol} - API response contains null values for non-nullable fields", symbol); + } + catch (InvalidCastException ex) + { + // Related to the JSON issue - null cannot be converted to value type + _logger.LogDebug(ex, "Type conversion error for {Symbol}", symbol); + } catch (Exception ex) { _logger.LogWarning(ex, "Could not retrieve value for {Symbol}", symbol); } - _cache.Set(symbol, price, TimeSpan.FromMinutes(15)); + + // Cache even failed lookups to prevent repeated API calls for problematic symbols + _cache.Set(symbol, price, TimeSpan.FromMinutes(price > 0 ? 15 : 5)); + await Task.CompletedTask; return price; } } diff --git a/src/CryptoTracker/Services/TransactionService.cs b/src/CryptoTracker/Services/TransactionService.cs index 22e0cce..87db7e5 100644 --- a/src/CryptoTracker/Services/TransactionService.cs +++ b/src/CryptoTracker/Services/TransactionService.cs @@ -177,7 +177,7 @@ public async Task SetHiddenAsync(FlowType flowType, int id, bool isHidden) var tradeQuery = _dbContext.CryptoTrades .Include(t => t.Wallet) .Include(t => t.OppositeTrade) - .ThenInclude(t => t.Wallet) + .ThenInclude(t => t!.Wallet) .AsQueryable(); if (includeHidden) @@ -217,9 +217,9 @@ public async Task SetHiddenAsync(FlowType flowType, int id, bool isHidden) .Include(t => t.Wallet) .Include(t => t.OppositeWallet) .Include(t => t.OppositeTransaction) - .ThenInclude(t => t.Wallet) + .ThenInclude(t => t!.Wallet) .Include(t => t.OppositeTransaction) - .ThenInclude(t => t.OppositeWallet) + .ThenInclude(t => t!.OppositeWallet) .AsQueryable(); if (includeHidden) diff --git a/src/CryptoTracker/Startup.cs b/src/CryptoTracker/Startup.cs index 0915592..80dc15f 100644 --- a/src/CryptoTracker/Startup.cs +++ b/src/CryptoTracker/Startup.cs @@ -7,6 +7,13 @@ using CryptoTracker.Shared; using CryptoTracker.Controllers; using Radzen; +using CryptoTracker.Agent.Common; +using CryptoTracker.Agent.Definitions; +using CryptoTracker.Agent.Services; +using CryptoTracker.Agent.Tools; +using Azure.AI.OpenAI; +using Azure; +using Azure.Identity; namespace CryptoTracker { @@ -69,11 +76,68 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); + // === AI Linking Agent Services === + // OpenAI settings werden direkt aus Environment-Variablen / secrets.json gebunden + services.Configure(options => + { + options.OpenAiEndpoint = Configuration["OpenAiEndpoint"] ?? ""; + options.OpenAiDeploymentName = Configuration["OpenAiDeploymentName"] ?? "gpt-5.2"; + options.OpenAiFastDeploymentName = Configuration["OpenAiFastDeploymentName"] ?? "gpt-5-nano"; + options.OpenAiEmbeddingDeploymentName = Configuration["OpenAiEmbeddingDeploymentName"] ?? "text-embedding-3-large"; + if (int.TryParse(Configuration["OpenAiEmbeddingVectorDimensions"], out var dims)) + options.OpenAiEmbeddingVectorDimensions = dims; + options.OpenAiKey = Configuration["OpenAiKey"]; + }); + + // Azure OpenAI Client (mit API Key oder DefaultAzureCredential) + services.AddSingleton(sp => + { + var endpoint = Configuration["OpenAiEndpoint"]; + var apiKey = Configuration["OpenAiKey"]; + + if (string.IsNullOrEmpty(endpoint)) + { + // Dummy-Client wenn nicht konfiguriert + return new AzureOpenAIClient( + new Uri("https://placeholder.openai.azure.com/"), + new AzureKeyCredential("placeholder")); + } + + if (!string.IsNullOrEmpty(apiKey)) + { + return new AzureOpenAIClient( + new Uri(endpoint), + new AzureKeyCredential(apiKey)); + } + return new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()); + }); + + // Agent Builder + services.AddScoped(); + + // Agent Tools + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Agent Definitions + services.AddScoped(); + + // Agent Services + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/CryptoTracker/wwwroot/app.css b/src/CryptoTracker/wwwroot/app.css index 96600fc..9b97a97 100644 --- a/src/CryptoTracker/wwwroot/app.css +++ b/src/CryptoTracker/wwwroot/app.css @@ -1392,3 +1392,1373 @@ h1, h2, h3, .app-title { font-weight: 500; } +/* ============================================ + TRANSACTION LINKING STYLES + ============================================ */ + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--surface-100); + border: 1px solid var(--surface-300); + border-radius: var(--radius-md); + padding: 1rem; + text-align: center; + box-shadow: var(--shadow-tight); +} + +.stat-value { + font-size: 1.8rem; + font-weight: 700; + color: var(--ink-900); + font-variant-numeric: tabular-nums; +} + +.stat-label { + font-size: 0.8rem; + color: var(--ink-500); + margin-top: 0.25rem; +} + +.stat-card.stat-success { + border-left: 4px solid var(--mint-500); +} + +.stat-card.stat-success .stat-value { + color: var(--mint-500); +} + +.stat-card.stat-warning { + border-left: 4px solid #f0ad4e; +} + +.stat-card.stat-warning .stat-value { + color: #a26d00; +} + +.stat-card.stat-info { + border-left: 4px solid #17a2b8; +} + +.stat-card.stat-info .stat-value { + color: #17a2b8; +} + +.stat-card.stat-pending { + border-left: 4px solid var(--rose-500); +} + +.stat-card.stat-pending .stat-value { + color: var(--rose-500); +} + +/* Symbol Chips */ +.symbol-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--surface-200); + border-radius: var(--radius-md); +} + +.symbol-chips-label { + font-size: 0.85rem; + color: var(--ink-500); + margin-right: 0.5rem; +} + +.symbol-chip { + background: var(--surface-100); + border: 1px solid var(--surface-300); + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.symbol-chip:hover { + border-color: var(--ink-500); +} + +.symbol-chip.active { + background: var(--ink-700); + color: white; + border-color: var(--ink-700); +} + +.symbol-chip.clear { + background: transparent; + border-style: dashed; +} + +.chip-count { + font-weight: 600; + margin-left: 0.25rem; +} + +/* Type Tabs */ +.type-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.type-tab { + padding: 0.5rem 1rem; + border: 1px solid var(--surface-300); + background: var(--surface-100); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.2s ease; +} + +.type-tab:hover { + border-color: var(--ink-500); +} + +.type-tab.active { + background: var(--ink-700); + color: white; + border-color: var(--ink-700); +} + +/* Type Badge */ +.type-badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.type-badge.send { + background: rgba(194, 70, 84, 0.15); + color: var(--rose-500); +} + +.type-badge.receive { + background: rgba(31, 143, 106, 0.15); + color: var(--mint-500); +} + +/* Address Truncation */ +.address-truncate { + font-family: 'Roboto Mono', monospace; + font-size: 0.85rem; + color: var(--ink-500); +} + +/* Action Buttons */ +.action-buttons { + display: flex; + gap: 0.5rem; +} + +/* Empty State Success */ +.empty-state.success-state { + text-align: center; + padding: 3rem 2rem; +} + +.empty-icon { + font-size: 3rem; + color: var(--mint-500); + margin-bottom: 1rem; +} + +.empty-text { + font-size: 1.1rem; + color: var(--ink-700); +} + +/* Chat Panel */ +.chat-panel { + position: fixed; + right: 20px; + bottom: 20px; + width: 400px; + max-width: calc(100vw - 40px); + height: 500px; + max-height: calc(100vh - 100px); + background: var(--surface-100); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); + display: flex; + flex-direction: column; + z-index: 100; +} + +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--surface-300); +} + +.chat-header h3 { + margin: 0; + font-size: 1rem; +} + +.chat-close { + background: var(--surface-200); + border: none; + width: 28px; + height: 28px; + border-radius: 999px; + cursor: pointer; + font-size: 1rem; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.chat-message { + max-width: 85%; + padding: 0.75rem; + border-radius: var(--radius-md); +} + +.chat-message.user { + align-self: flex-end; + background: var(--ink-700); + color: white; +} + +.chat-message.assistant { + align-self: flex-start; + background: var(--surface-200); + color: var(--ink-900); +} + +.chat-content { + font-size: 0.9rem; + line-height: 1.5; +} + +.chat-content code { + background: rgba(0, 0, 0, 0.1); + padding: 0.1rem 0.3rem; + border-radius: 4px; + font-size: 0.85em; +} + +.chat-time { + font-size: 0.7rem; + color: var(--ink-500); + margin-top: 0.35rem; + text-align: right; +} + +.chat-message.user .chat-time { + color: rgba(255, 255, 255, 0.7); +} + +.chat-loading { + display: flex; + gap: 0.3rem; + padding: 0.5rem; +} + +.loading-dot { + width: 8px; + height: 8px; + background: var(--ink-500); + border-radius: 50%; + animation: chatBounce 1.4s ease-in-out infinite; +} + +.loading-dot:nth-child(1) { animation-delay: 0s; } +.loading-dot:nth-child(2) { animation-delay: 0.2s; } +.loading-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes chatBounce { + 0%, 80%, 100% { transform: translateY(0); } + 40% { transform: translateY(-6px); } +} + +.chat-input-area { + display: flex; + gap: 0.5rem; + padding: 1rem; + border-top: 1px solid var(--surface-300); +} + +.chat-input { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--surface-300); + border-radius: var(--radius-sm); + font-size: 0.9rem; +} + +.chat-input:focus { + outline: none; + border-color: var(--ink-700); +} + +.chat-send { + padding: 0.5rem 1rem; + background: var(--ink-700); + color: white; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; +} + +.chat-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Link Dialog */ +.link-dialog { + width: min(600px, 90vw); + max-height: 80vh; +} + +.link-source { + background: var(--surface-200); + border-radius: var(--radius-md); + padding: 1rem; +} + +.link-source-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.link-source-details { + font-size: 0.85rem; + color: var(--ink-500); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.link-arrow { + text-align: center; + font-size: 1.5rem; + color: var(--ink-500); + padding: 0.5rem; +} + +.link-target-search { + margin-top: 0.5rem; +} + +.match-list { + max-height: 300px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.match-item { + background: var(--surface-200); + border: 2px solid transparent; + border-radius: var(--radius-sm); + padding: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.match-item:hover { + border-color: var(--ink-500); +} + +.match-item.selected { + border-color: var(--ink-700); + background: var(--surface-100); +} + +.match-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.35rem; +} + +.match-wallet { + font-weight: 600; + font-size: 0.9rem; +} + +.match-details { + display: flex; + gap: 1rem; + font-size: 0.8rem; + color: var(--ink-500); +} + +.link-reason { + margin-top: 1rem; +} + +.link-reason label { + display: block; + font-size: 0.85rem; + color: var(--ink-500); + margin-bottom: 0.35rem; +} + +/* External Info */ +.external-info { + background: var(--surface-200); + border-radius: var(--radius-sm); + padding: 0.75rem; + margin: 1rem 0; + font-size: 0.9rem; +} + +.external-info div { + margin-bottom: 0.25rem; +} + +/* Lot Assignment Modal */ +.lot-assignment-modal { + width: min(700px, 92vw); + max-height: 85vh; +} + +.lot-assignment-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + background: var(--surface-200); + border-radius: var(--radius-sm); + padding: 0.75rem; +} + +.lot-assignment-info-item { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.lot-assignment-info-item label { + font-size: 0.7rem; + color: var(--ink-500); + text-transform: uppercase; +} + +.lot-assignment-info-item span { + font-weight: 500; + color: var(--ink-900); +} + +.lot-assignment-confirmed { + text-align: center; + padding: 1rem; +} + +/* Form Controls */ +.form-control { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--surface-300); + border-radius: var(--radius-sm); + font-size: 0.9rem; +} + +.form-control:focus { + outline: none; + border-color: var(--ink-700); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-check { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.form-check-input { + width: 1rem; + height: 1rem; +} + +.form-check-label { + font-size: 0.9rem; + color: var(--ink-700); +} + +.alert { + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + margin-top: 1rem; +} + +.alert-danger { + background: rgba(194, 70, 84, 0.1); + color: var(--rose-500); + border: 1px solid rgba(194, 70, 84, 0.2); +} + +.alert-success { + background: rgba(31, 143, 106, 0.1); + color: var(--mint-500); + border: 1px solid rgba(31, 143, 106, 0.2); +} + +/* Button variants */ +.btn-danger { + background: var(--rose-500); + border-color: var(--rose-500); + color: white; +} + +.btn-danger:hover { + background: #a83b47; + border-color: #a83b47; +} + +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.85rem; +} + +.lot-button { + border: none; + background: rgba(31, 143, 106, 0.15); + color: var(--mint-500); + padding: 0.35rem 0.8rem; + border-radius: 999px; + font-weight: 600; + cursor: pointer; +} + +.lot-button:hover { + background: rgba(31, 143, 106, 0.25); +} + +/* ============================================ + LINKING WIZARD STYLES + ============================================ */ + +.wizard-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.wizard-container { + background: var(--surface-100); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); + width: min(800px, 95vw); + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wizard-header { + padding: 1.5rem; + border-bottom: 1px solid var(--surface-300); + position: relative; +} + +.wizard-header h2 { + margin: 0; + font-size: 1.4rem; +} + +.wizard-subtitle { + color: var(--ink-500); + font-size: 0.9rem; + margin-top: 0.25rem; +} + +.wizard-close { + position: absolute; + top: 1rem; + right: 1rem; + background: var(--surface-200); + border: none; + width: 32px; + height: 32px; + border-radius: 999px; + cursor: pointer; + font-size: 1.2rem; +} + +.wizard-progress { + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: var(--surface-200); +} + +.wizard-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; +} + +.wizard-step-number { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--surface-300); + color: var(--ink-500); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.9rem; + transition: all 0.3s ease; +} + +.wizard-step.active .wizard-step-number { + background: var(--ink-700); + color: white; +} + +.wizard-step.completed .wizard-step-number { + background: var(--mint-500); + color: white; +} + +.check-icon { + font-size: 1rem; +} + +.wizard-step-label { + font-size: 0.75rem; + color: var(--ink-500); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.wizard-step.active .wizard-step-label { + color: var(--ink-700); + font-weight: 600; +} + +.wizard-step-connector { + width: 60px; + height: 2px; + background: var(--surface-300); + margin: 0 0.5rem; + margin-bottom: 1.5rem; +} + +.wizard-step-connector.completed { + background: var(--mint-500); +} + +.wizard-content { + flex: 1; + overflow-y: auto; + padding: 2rem; +} + +.wizard-step-content { + text-align: center; +} + +.wizard-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.wizard-icon.success { + color: var(--mint-500); +} + +.wizard-step-content h3 { + margin: 0 0 1rem 0; +} + +.wizard-step-content p { + color: var(--ink-500); + line-height: 1.6; + max-width: 600px; + margin: 0 auto 1.5rem; +} + +.wizard-info-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; + margin-top: 2rem; + text-align: left; +} + +.wizard-info-card { + background: var(--surface-200); + border-radius: var(--radius-md); + padding: 1.25rem; +} + +.info-icon { + font-size: 1.5rem; + margin-bottom: 0.5rem; +} + +.wizard-info-card h4 { + margin: 0 0 0.5rem 0; + font-size: 1rem; +} + +.wizard-info-card p { + margin: 0; + font-size: 0.85rem; +} + +.wizard-stats-preview { + margin-top: 2rem; + background: var(--surface-200); + border-radius: var(--radius-md); + padding: 1.25rem; +} + +.wizard-stats-preview h4 { + margin: 0 0 1rem 0; + font-size: 0.9rem; + color: var(--ink-500); +} + +.stats-row { + display: flex; + justify-content: center; + gap: 2rem; +} + +.stat-item { + text-align: center; +} + +.stat-number { + display: block; + font-size: 1.8rem; + font-weight: 700; + color: var(--ink-900); +} + +.stat-label { + font-size: 0.75rem; + color: var(--ink-500); +} + +.wizard-criteria-list { + text-align: left; + max-width: 500px; + margin: 0 auto 1.5rem; +} + +.wizard-criteria-list li { + margin-bottom: 0.5rem; + color: var(--ink-700); +} + +.wizard-action-area { + margin-top: 2rem; +} + +.btn-lg { + padding: 0.75rem 2rem; + font-size: 1.1rem; +} + +.wizard-progress-area { + margin-top: 2rem; +} + +.loading-spinner-large { + width: 48px; + height: 48px; + border: 4px solid var(--surface-300); + border-radius: 50%; + border-top-color: var(--ink-700); + animation: spin 0.8s linear infinite; + margin: 0 auto 1rem; +} + +.wizard-result-area { + margin-top: 1.5rem; +} + +.result-success { + margin-bottom: 1.5rem; +} + +.result-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: var(--mint-500); + color: white; + border-radius: 50%; + font-size: 1.5rem; + margin-bottom: 0.5rem; +} + +.result-stats { + display: flex; + justify-content: center; + gap: 2rem; + margin-bottom: 1.5rem; +} + +.result-stat { + text-align: center; +} + +.result-number { + display: block; + font-size: 2rem; + font-weight: 700; +} + +.result-number.success { + color: var(--mint-500); +} + +.result-number.info { + color: #17a2b8; +} + +.result-number.warning { + color: #f0ad4e; +} + +.result-label { + font-size: 0.75rem; + color: var(--ink-500); +} + +.result-summary { + background: var(--surface-200); + border-radius: var(--radius-md); + padding: 1rem; + text-align: left; + margin-top: 1rem; +} + +.result-summary h5 { + margin: 0 0 0.5rem 0; + font-size: 0.85rem; + color: var(--ink-500); +} + +.result-summary p { + margin: 0; + font-size: 0.9rem; +} + +.wizard-success-state { + padding: 2rem; +} + +.success-icon { + font-size: 3rem; + color: var(--mint-500); + margin-bottom: 1rem; +} + +.wizard-transaction-list { + max-height: 400px; + overflow-y: auto; + margin-top: 1rem; +} + +.wizard-transaction-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--surface-200); + border-radius: var(--radius-sm); + margin-bottom: 0.5rem; + text-align: left; +} + +.tx-info { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.tx-symbol { + font-weight: 600; +} + +.tx-amount { + font-family: 'Roboto Mono', monospace; + font-size: 0.85rem; +} + +.tx-wallet { + color: var(--ink-500); + font-size: 0.85rem; +} + +.tx-date { + color: var(--ink-500); + font-size: 0.8rem; +} + +.tx-actions { + display: flex; + gap: 0.5rem; +} + +.wizard-final-stats { + display: flex; + justify-content: center; + gap: 1.5rem; + margin: 2rem 0; +} + +.final-stat-card { + background: var(--surface-200); + border-radius: var(--radius-md); + padding: 1.5rem 2rem; + text-align: center; + min-width: 140px; +} + +.final-stat-card.success { + border-top: 4px solid var(--mint-500); +} + +.final-stat-card.info { + border-top: 4px solid #17a2b8; +} + +.final-stat-card.warning { + border-top: 4px solid #f0ad4e; +} + +.final-stat-number { + display: block; + font-size: 2.5rem; + font-weight: 700; + color: var(--ink-900); +} + +.final-stat-label { + display: block; + font-size: 0.8rem; + color: var(--ink-500); + margin-top: 0.25rem; +} + +.final-stat-percent { + display: block; + font-size: 0.85rem; + color: var(--mint-500); + font-weight: 600; + margin-top: 0.25rem; +} + +.wizard-next-steps { + background: var(--surface-200); + border-radius: var(--radius-md); + padding: 1.5rem; + text-align: left; + max-width: 500px; + margin: 0 auto; +} + +.wizard-next-steps h4 { + margin: 0 0 1rem 0; +} + +.wizard-next-steps ul { + margin: 0; + padding-left: 1.25rem; +} + +.wizard-next-steps li { + margin-bottom: 0.5rem; + color: var(--ink-700); +} + +.wizard-footer { + display: flex; + align-items: center; + padding: 1rem 1.5rem; + border-top: 1px solid var(--surface-300); +} + +.wizard-footer-spacer { + flex: 1; +} + +/* ============================================ + LOT FLOW VISUALIZATION STYLES + ============================================ */ + +.lot-flow-visualization { + background: var(--surface-100); + border: 1px solid var(--surface-300); + border-radius: var(--radius-md); + padding: 1.5rem; +} + +.flow-empty { + text-align: center; + color: var(--ink-500); + padding: 2rem; +} + +.flow-header { + margin-bottom: 1.5rem; +} + +.flow-title { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.flow-symbol { + font-size: 1.4rem; + font-weight: 700; + color: var(--ink-900); +} + +.flow-quantity { + font-family: 'Roboto Mono', monospace; + font-size: 1.1rem; + color: var(--ink-700); +} + +.flow-status { + padding: 0.25rem 0.75rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.flow-status.complete { + background: rgba(31, 143, 106, 0.15); + color: var(--mint-500); +} + +.flow-status.incomplete { + background: rgba(194, 70, 84, 0.15); + color: var(--rose-500); +} + +.flow-warning { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + padding: 0.75rem; + background: rgba(194, 70, 84, 0.08); + border-radius: var(--radius-sm); + color: var(--rose-500); + font-size: 0.9rem; +} + +.warning-icon { + font-size: 1.1rem; +} + +.flow-chain { + display: flex; + flex-direction: column; + gap: 0; +} + +.flow-connector { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 0; +} + +.connector-line { + width: 2px; + height: 20px; + background: var(--surface-300); +} + +.connector-arrow { + color: var(--ink-500); + font-size: 1.2rem; +} + +.flow-step { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--surface-200); + border-radius: var(--radius-md); + border-left: 4px solid var(--surface-300); +} + +.flow-step.step-inflow { + border-left-color: var(--mint-500); +} + +.flow-step.step-outflow { + border-left-color: var(--rose-500); +} + +.flow-step.step-transfer { + border-left-color: var(--ink-500); +} + +.flow-step.step-swap { + border-left-color: #0d6efd; +} + +.step-icon { + font-size: 1.5rem; +} + +.step-content { + flex: 1; +} + +.step-type { + font-weight: 600; + color: var(--ink-900); + margin-bottom: 0.25rem; +} + +.step-details { + display: flex; + gap: 1rem; + font-size: 0.85rem; + color: var(--ink-500); +} + +.step-ref { + font-size: 0.75rem; + color: var(--ink-500); + margin-top: 0.25rem; +} + +.step-lot-id { + font-size: 0.75rem; + color: var(--ink-500); + background: var(--surface-300); + padding: 0.2rem 0.5rem; + border-radius: 999px; +} + +.flow-summary { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--surface-300); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; +} + +.summary-item { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.summary-label { + font-size: 0.75rem; + color: var(--ink-500); + text-transform: uppercase; +} + +.summary-value { + font-weight: 600; + color: var(--ink-900); +} + +.summary-value.altbestand { + color: var(--mint-500); +} + +.summary-value.neubestand { + color: var(--ink-700); +} + +/* Tab badge */ +.tab-badge { + background: var(--rose-500); + color: white; + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + border-radius: 999px; + margin-left: 0.5rem; +} + +.type-tab.active .tab-badge { + background: white; + color: var(--ink-700); +} + +/* Section header */ +.section-header { + margin-bottom: 1rem; +} + +.section-header h3 { + margin: 0 0 0.25rem 0; +} + +/* Lot allocation list */ +.lot-allocation-list { + max-height: 300px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.lot-allocation-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--surface-200); + border-radius: var(--radius-sm); + border: 2px solid transparent; + transition: all 0.2s ease; +} + +.lot-allocation-item.selected { + border-color: var(--ink-700); + background: var(--surface-100); +} + +.lot-info { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.lot-date { + font-size: 0.85rem; + color: var(--ink-500); +} + +.lot-available { + font-size: 0.85rem; + color: var(--ink-700); +} + +.lot-price { + font-size: 0.8rem; + color: var(--ink-500); +} + +.lot-allocation-input { + width: 120px; +} + +.lot-allocation-input input { + width: 100%; + padding: 0.35rem 0.5rem; + border: 1px solid var(--surface-300); + border-radius: var(--radius-sm); + font-size: 0.85rem; +} + +.allocation-summary { + margin-top: 1rem; + padding: 0.75rem; + background: var(--surface-200); + border-radius: var(--radius-sm); + display: flex; + justify-content: space-between; + font-size: 0.9rem; +} + +/* Assignment source */ +.assignment-source { + background: var(--surface-200); + border-radius: var(--radius-md); + padding: 1rem; + margin-bottom: 1rem; +} + +.assignment-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.assignment-details { + font-size: 0.85rem; + color: var(--ink-500); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.assignment-lots h4 { + margin: 0 0 0.5rem 0; + font-size: 0.95rem; +} + +/* Modal large */ +.modal-large { + width: min(800px, 95vw); + max-height: 85vh; +} + +/* Flow search */ +.flow-search { + margin-bottom: 1.5rem; +} + +.input-group { + display: flex; + gap: 0.5rem; +} + +.input-group input { + flex: 1; + max-width: 200px; +} + From 93feff1c3294920fe84f329b54fb87a4021c3b38 Mon Sep 17 00:00:00 2001 From: Thomas Humer Date: Sat, 31 Jan 2026 23:25:49 +0100 Subject: [PATCH 4/7] feat: Implement interactive linking service with SignalR integration - Added InteractiveLinkingService to manage interactive linking sessions. - Introduced LinkingHub for real-time communication during linking processes. - Enhanced TransactionLinkingController with new interactive methods for session management. - Updated LotsController to include WalletId in transaction responses. - Implemented methods for calculating hints and managing learned rules. - Added DTOs for linking context, questions, and events. - Integrated SignalR for live updates and user interactions during the linking process. --- .../CryptoTracker.Client.csproj | 1 + .../Pages/LotsLinking.razor.cs | 3 +- .../Shared/Api/ITransactionLinkingApi.cs | 37 + .../Shared/LinkingWizard.razor | 640 ++++++++----- .../Shared/LinkingWizard.razor.cs | 415 +++++++-- src/CryptoTracker.Client/Shared/LotDTOs.cs | 1 + .../Shared/TransactionLinkingDTOs.cs | 83 ++ .../Services/InteractiveLinkingService.cs | 840 ++++++++++++++++++ .../Services/TransactionLinkingService.cs | 223 ++++- .../Controllers/LotsController.cs | 2 + .../TransactionLinkingController.cs | 210 +++++ src/CryptoTracker/Hubs/LinkingHub.cs | 69 ++ src/CryptoTracker/Startup.cs | 6 + 13 files changed, 2208 insertions(+), 322 deletions(-) create mode 100644 src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs create mode 100644 src/CryptoTracker/Hubs/LinkingHub.cs diff --git a/src/CryptoTracker.Client/CryptoTracker.Client.csproj b/src/CryptoTracker.Client/CryptoTracker.Client.csproj index f764cfe..ccb3b02 100644 --- a/src/CryptoTracker.Client/CryptoTracker.Client.csproj +++ b/src/CryptoTracker.Client/CryptoTracker.Client.csproj @@ -11,6 +11,7 @@ + diff --git a/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs b/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs index c071e45..90dea5e 100644 --- a/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs +++ b/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs @@ -248,10 +248,9 @@ private async Task CreateLot() try { - // Look up wallet ID from name (simplified - real implementation needs API) var request = new CreateManualLotRequest( Symbol: CreateLotForAssignment.Symbol, - WalletId: 0, // Would need to resolve from wallet name + WalletId: CreateLotForAssignment.WalletId, Quantity: CreateLotForAssignment.Quantity, AcquisitionDate: new DateTimeOffset(NewLotAcquisitionDate, TimeSpan.Zero), AcquisitionPriceEur: NewLotAcquisitionPrice, diff --git a/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs b/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs index 719f8d2..73c8534 100644 --- a/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs +++ b/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs @@ -54,4 +54,41 @@ public interface ITransactionLinkingApi /// Markiert eine Transaktion als absichtlich unverknüpft (externe Einnahme) /// Task MarkAsIntentionallyUnlinkedAsync(int transactionId, string reason); + + // === Neue interaktive Linking-Methoden === + + /// + /// Startet eine neue interaktive Linking-Session + /// + Task StartInteractiveSessionAsync(); + + /// + /// Gibt den Status einer interaktiven Session zurück + /// + Task GetInteractiveSessionStatusAsync(string sessionId); + + /// + /// Sendet User-Antwort an interaktive Session + /// + Task SubmitUserResponseAsync(string sessionId, UserResponseDTO response); + + /// + /// Stoppt eine interaktive Session + /// + Task StopInteractiveSessionAsync(string sessionId); + + /// + /// Gibt den Linking-Context (alle Daten + Hints) zurück + /// + Task GetLinkingContextAsync(); + + /// + /// Gibt gelernte Regeln zurück + /// + Task> GetLearnedRulesAsync(); + + /// + /// Löscht eine gelernte Regel + /// + Task DeleteLearnedRuleAsync(string ruleId); } diff --git a/src/CryptoTracker.Client/Shared/LinkingWizard.razor b/src/CryptoTracker.Client/Shared/LinkingWizard.razor index a2e3a43..4db6256 100644 --- a/src/CryptoTracker.Client/Shared/LinkingWizard.razor +++ b/src/CryptoTracker.Client/Shared/LinkingWizard.razor @@ -1,69 +1,142 @@ @using CryptoTracker.Shared +@using Microsoft.AspNetCore.SignalR.Client +@implements IAsyncDisposable
-
+

Transaktions-Verknüpfung

-

Schritt @CurrentStep von @TotalSteps

- +

@GetStatusText()

+
-
- @for (int i = 1; i <= TotalSteps; i++) +
+ @* Progress Section *@ +
+
+
+
+
+ @ProcessedCount / @TotalCount Transaktionen + @if (LinkedCount > 0 || MarkedExternalCount > 0 || SkippedCount > 0) + { + + ✓ @LinkedCount verknüpft | + ⊕ @MarkedExternalCount extern | + ⏭ @SkippedCount übersprungen + + } +
+
+ + @* Live Event Log *@ +
+ @foreach (var evt in EventLog) + { +
+ @evt.Icon + @evt.Message +
+ } + @if (IsProcessing && CurrentQuestion == null) + { +
+ + @ProcessingMessage +
+ } +
+ + @* Question Section (inline) *@ + @if (CurrentQuestion != null) { - var step = i; -
-
- @if (step < CurrentStep) +
+
+ + Agent fragt: +
+
+

@CurrentQuestion

+ @if (CurrentTransaction != null) { - +
+ + @(CurrentTransaction.IsSend ? "Send" : "Receive") + + @CurrentTransaction.Symbol + @CurrentTransaction.Quantity.ToString("N8") + @CurrentTransaction.WalletName + @CurrentTransaction.DateTime.ToString("dd.MM.yyyy HH:mm") + @if (!string.IsNullOrEmpty(CurrentTransaction.Comment)) + { + "@CurrentTransaction.Comment" + } +
} - else +
+
+ @if (CurrentOptions != null) { - @step + @foreach (var option in CurrentOptions) + { + + } }
-
@GetStepLabel(step)
+
+ +
- @if (step < TotalSteps) - { -
- } } -
-
- @if (CurrentStep == 1) + @* Learned Rules Section *@ + @if (LearnedRules.Count > 0) { - @* Step 1: Welcome *@ -
-
🔗
-

Willkommen zur Transaktions-Verknüpfung

-

- Dieser Assistent hilft dir, Send- und Receive-Transaktionen zwischen deinen Wallets zu verknüpfen. - Das ist wichtig für die korrekte Steuerberechnung. -

- -
-
-
🔬
-

Warum verknüpfen?

-

Transfers zwischen eigenen Wallets sind keine steuerpflichtigen Ereignisse. - Die Verknüpfung stellt sicher, dass Lot-Informationen (Kaufdatum, Kaufpreis) - korrekt weitergegeben werden.

-
-
-
🤖
-

KI-Unterstützung

-

Unser KI-Assistent analysiert deine Transaktionen und schlägt automatisch - passende Verknüpfungen vor. Du behältst die volle Kontrolle.

-
+
+
+ @(RulesExpanded ? "▼" : "▶") Gelernte Regeln (@LearnedRules.Count)
+ @if (RulesExpanded) + { +
+ @foreach (var rule in LearnedRules) + { +
+ "@rule.Pattern" + + @rule.Description + (@rule.TimesApplied× angewendet) + +
+ } +
+ } +
+ } + @* Start / Completion Section *@ + @if (!IsStarted) + { +
+
🔗
+

Interaktive Transaktions-Verknüpfung

+

+ Der Assistent verknüpft automatisch passende Send/Receive-Paare. + Bei unklaren Fällen wirst du um Bestätigung gebeten. +

@if (Statistics != null) {
-

Aktuelle Situation

@Statistics.UnlinkedTransactions @@ -80,199 +153,342 @@
} -
- } - else if (CurrentStep == 2) - { - @* Step 2: Auto-Link *@ -
-
-

Automatische Verknüpfung

-

- Klicke auf "Auto-Verknüpfung starten", um die KI-gestützte Analyse zu starten. - Der Algorithmus sucht nach passenden Send/Receive-Paaren basierend auf: -

-
    -
  • Gleiches Asset (z.B. BTC zu BTC)
  • -
  • Ähnliche Menge (unter Berücksichtigung von Gebühren)
  • -
  • Zeitliche Nähe (typischerweise innerhalb von Minuten bis Stunden)
  • -
  • Bekannte Wallet-Adressen
  • -
- - @if (!IsAutoLinking && AutoLinkResult == null) - { -
- -
- } - else if (IsAutoLinking) - { -
-
-

Analysiere Transaktionen...

-
- } - else if (AutoLinkResult != null) - { -
-
- -

Analyse abgeschlossen!

-
-
-
- @AutoLinkResult.LinkedCount - Automatisch verknüpft -
-
- @AutoLinkResult.MarkedUnlinkedCount - Als extern markiert -
-
- @AutoLinkResult.RemainingUnlinkedCount - Noch offen -
-
- @if (!string.IsNullOrEmpty(AutoLinkResult.Summary)) +
+
- } + else + { + Verknüpfung starten + } + +
} - else if (CurrentStep == 3) + else if (IsCompleted) { - @* Step 3: Manual Review *@ -
-
🔎
-

Manuelle Überprüfung

- - @if (RemainingUnlinked.Count == 0) - { -
-
-

Alle Transaktionen verknüpft!

-

Es gibt keine weiteren unverknüpften Transaktionen.

+
+
+

Verknüpfung abgeschlossen!

+
+
+ @LinkedCount + Verknüpft
- } - else - { -

- Diese @RemainingUnlinked.Count Transaktionen konnten nicht automatisch zugeordnet werden. - Bitte überprüfe sie manuell. -

- -
- @foreach (var tx in RemainingUnlinked.Take(10)) - { -
-
- - @(tx.IsSend ? "Send" : "Receive") - - @tx.Symbol - @tx.Quantity.ToString("N8") - @tx.WalletName - @tx.DateTime.ToString("dd.MM.yyyy HH:mm") -
-
- - -
-
- } +
+ @MarkedExternalCount + Extern markiert
- - @if (RemainingUnlinked.Count > 10) + @if (SkippedCount > 0) { -

- Und @(RemainingUnlinked.Count - 10) weitere... - Du kannst nach dem Wizard auf der Hauptseite alle bearbeiten. -

+
+ @SkippedCount + Übersprungen +
} - } +
} - else if (CurrentStep == 4) - { - @* Step 4: Summary *@ -
-
-

Verknüpfung abgeschlossen!

- - @if (FinalStatistics != null) - { -
-
- @FinalStatistics.LinkedTransactions - Verknüpfte Transaktionen - @FinalStatistics.LinkedPercent% -
-
- @FinalStatistics.IntentionallyUnlinked - Externe Transaktionen -
- @if (FinalStatistics.UnlinkedTransactions > 0) - { -
- @FinalStatistics.UnlinkedTransactions - Noch offen -
- } -
-
-

Nächste Schritte

-
    - @if (FinalStatistics.UnlinkedTransactions > 0) - { -
  • Bearbeite die verbleibenden @FinalStatistics.UnlinkedTransactions offenen Transaktionen auf der Hauptseite
  • - } -
  • Überprüfe die Lot-Zuordnungen unter "Steuerlots"
  • -
  • Generiere deinen Steuerbericht unter "Bilanzen"
  • -
-
- } + @* Error Display *@ + @if (!string.IsNullOrEmpty(ErrorMessage)) + { +
+ @ErrorMessage
}
+ + diff --git a/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs b/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs index be0329b..0fafb39 100644 --- a/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs +++ b/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs @@ -1,142 +1,415 @@ using CryptoTracker.Shared; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; namespace CryptoTracker.Client.Shared; -public partial class LinkingWizard +public partial class LinkingWizard : IAsyncDisposable { [Parameter] public EventCallback OnComplete { get; set; } [Parameter] public EventCallback OnCancel { get; set; } [Parameter] public LinkingStatisticsDTO? Statistics { get; set; } [Inject] private ITransactionLinkingApi LinkingApi { get; set; } = default!; + [Inject] private NavigationManager NavigationManager { get; set; } = default!; - private int CurrentStep = 1; - private const int TotalSteps = 4; + // State + private bool IsStarted = false; + private bool IsConnecting = false; private bool IsProcessing = false; - private bool IsAutoLinking = false; - private AutoLinkResultDTO? AutoLinkResult; - private IList RemainingUnlinked = new List(); - private LinkingStatisticsDTO? FinalStatistics; + private bool IsCompleted = false; + private bool IsAnswering = false; + private string? ErrorMessage; - private bool CanProceed => CurrentStep switch + // Progress + private int ProcessedCount = 0; + private int TotalCount = 0; + private int LinkedCount = 0; + private int MarkedExternalCount = 0; + private int SkippedCount = 0; + private string ProcessingMessage = "Starte..."; + + // Question state + private string? CurrentQuestionId; + private string? CurrentQuestion; + private UnlinkedTransactionDTO? CurrentTransaction; + private IList? CurrentOptions; + private bool ShouldRemember = true; + + // Learned rules + private List LearnedRules = new(); + private bool RulesExpanded = false; + + // Event log + private List EventLog = new(); + private ElementReference eventLogElement; + + // Session + private string? SessionId; + private HubConnection? hubConnection; + + private int ProgressPercent => TotalCount > 0 ? (int)(ProcessedCount * 100.0 / TotalCount) : 0; + + protected override async Task OnInitializedAsync() { - 1 => true, - 2 => AutoLinkResult != null, - 3 => true, - 4 => true, - _ => false - }; + // Load learned rules + try + { + var rules = await LinkingApi.GetLearnedRulesAsync(); + LearnedRules = rules.ToList(); + } + catch + { + // Ignore - rules are optional + } + } - private string GetStepLabel(int step) => step switch + private string GetStatusText() { - 1 => "Übersicht", - 2 => "Auto-Link", - 3 => "Prüfen", - 4 => "Fertig", - _ => "" - }; + if (!IsStarted) return "Bereit zum Starten"; + if (IsCompleted) return "Abgeschlossen"; + if (CurrentQuestion != null) return "Warte auf Eingabe"; + return $"Verarbeite... ({ProcessedCount}/{TotalCount})"; + } - private async Task NextStep() + private async Task StartInteractiveSession() { - if (CurrentStep == 2 && AutoLinkResult != null) + IsConnecting = true; + ErrorMessage = null; + StateHasChanged(); + + try { - // Load remaining unlinked for step 3 + // Start session via API + var session = await LinkingApi.StartInteractiveSessionAsync(); + SessionId = session.SessionId; + TotalCount = session.TotalCount; + + // Build SignalR connection + var hubUrl = NavigationManager.ToAbsoluteUri("/hubs/linking"); + hubConnection = new HubConnectionBuilder() + .WithUrl(hubUrl) + .WithAutomaticReconnect() + .Build(); + + // Register event handlers + hubConnection.On("OnLinkingEvent", HandleLinkingEvent); + hubConnection.On("OnSessionUpdate", HandleSessionUpdate); + hubConnection.On("OnError", HandleError); + hubConnection.On("OnSessionCompleted", HandleSessionCompleted); + + // Connect and join session + await hubConnection.StartAsync(); + await hubConnection.InvokeAsync("JoinSession", SessionId); + + IsStarted = true; IsProcessing = true; - try - { - RemainingUnlinked = await LinkingApi.GetUnlinkedAsync(); - } - finally - { - IsProcessing = false; - } + ProcessingMessage = "Analysiere Transaktionen..."; + AddEvent("start", "Interaktive Verknüpfung gestartet"); } - - if (CurrentStep == 3) + catch (Exception ex) { - // Load final statistics for step 4 - IsProcessing = true; - try + ErrorMessage = $"Fehler beim Starten: {ex.Message}"; + } + finally + { + IsConnecting = false; + StateHasChanged(); + } + } + + private async Task StopSession() + { + if (SessionId == null) return; + + try + { + await LinkingApi.StopInteractiveSessionAsync(SessionId); + AddEvent("stop", "Session abgebrochen"); + IsCompleted = true; + IsProcessing = false; + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Stoppen: {ex.Message}"; + } + + StateHasChanged(); + } + + private void HandleLinkingEvent(LinkingEventDTO evt) + { + InvokeAsync(() => + { + switch (evt.EventType) { - FinalStatistics = await LinkingApi.GetStatisticsAsync(); + case "linked": + LinkedCount++; + ProcessedCount = evt.ProcessedCount; + AddEvent("linked", evt.Message); + break; + + case "marked_external": + MarkedExternalCount++; + ProcessedCount = evt.ProcessedCount; + AddEvent("external", evt.Message); + break; + + case "skipped": + SkippedCount++; + ProcessedCount = evt.ProcessedCount; + AddEvent("skipped", evt.Message); + break; + + case "question": + CurrentQuestionId = evt.QuestionId; + CurrentQuestion = evt.Message; + CurrentTransaction = evt.Transaction; + CurrentOptions = evt.Options; + IsProcessing = false; + break; + + case "progress": + ProcessedCount = evt.ProcessedCount; + TotalCount = evt.TotalCount; + ProcessingMessage = evt.Message; + break; + + case "rule_learned": + AddEvent("rule", evt.Message); + // Refresh rules list + _ = RefreshRulesAsync(); + break; + + case "error": + AddEvent("error", evt.Message); + break; } - finally + + StateHasChanged(); + _ = ScrollEventLogToBottom(); + }); + } + + private void HandleSessionUpdate(InteractiveLinkingSessionDTO session) + { + InvokeAsync(() => + { + ProcessedCount = session.ProcessedCount; + TotalCount = session.TotalCount; + LinkedCount = session.LinkedCount; + MarkedExternalCount = session.MarkedExternalCount; + SkippedCount = session.SkippedCount; + + if (session.CurrentQuestionId != null) { + CurrentQuestionId = session.CurrentQuestionId; + CurrentQuestion = session.CurrentQuestion; + CurrentTransaction = session.CurrentTransaction; + CurrentOptions = session.CurrentOptions; IsProcessing = false; } - } - if (CurrentStep < TotalSteps) + StateHasChanged(); + }); + } + + private void HandleError(string error) + { + InvokeAsync(() => { - CurrentStep++; - } + ErrorMessage = error; + AddEvent("error", error); + StateHasChanged(); + }); } - private void PreviousStep() + private void HandleSessionCompleted(LinkingStatisticsDTO stats) { - if (CurrentStep > 1) + InvokeAsync(() => { - CurrentStep--; - } + IsCompleted = true; + IsProcessing = false; + CurrentQuestion = null; + AddEvent("complete", $"Fertig! {LinkedCount} verknüpft, {MarkedExternalCount} extern, {SkippedCount} übersprungen"); + StateHasChanged(); + }); } - private async Task RunAutoLink() + private async Task AnswerQuestion(string answer) { - IsAutoLinking = true; + if (CurrentQuestionId == null || SessionId == null) return; + + IsAnswering = true; StateHasChanged(); try { - AutoLinkResult = await LinkingApi.RunAutoLinkAsync(); + var response = new UserResponseDTO + { + QuestionId = CurrentQuestionId, + Response = answer, + ShouldRemember = ShouldRemember + }; + + // Send via SignalR + if (hubConnection?.State == HubConnectionState.Connected) + { + await hubConnection.InvokeAsync("SendUserResponse", SessionId, response); + } + else + { + // Fallback to API + await LinkingApi.SubmitUserResponseAsync(SessionId, response); + } + + // Clear question + CurrentQuestionId = null; + CurrentQuestion = null; + CurrentTransaction = null; + CurrentOptions = null; + IsProcessing = true; + ProcessingMessage = "Verarbeite Antwort..."; } catch (Exception ex) { - AutoLinkResult = new AutoLinkResultDTO - { - Success = false, - Summary = $"Fehler: {ex.Message}" - }; + ErrorMessage = $"Fehler beim Senden: {ex.Message}"; } finally { - IsAutoLinking = false; + IsAnswering = false; StateHasChanged(); } } - private async Task OnLinkTransaction(UnlinkedTransactionDTO tx) + private async Task RefreshRulesAsync() { - // This would open a sub-dialog for linking - // For now, we'll just refresh the list - // The actual linking happens on the main page - await Task.CompletedTask; + try + { + var rules = await LinkingApi.GetLearnedRulesAsync(); + LearnedRules = rules.ToList(); + StateHasChanged(); + } + catch + { + // Ignore + } } - private async Task OnMarkExternal(UnlinkedTransactionDTO tx) + private async Task DeleteRule(string ruleId) { try { - var reason = tx.IsReceive ? "Externe Einnahme (via Wizard)" : "Externe Ausgabe (via Wizard)"; - var result = await LinkingApi.MarkAsIntentionallyUnlinkedAsync(tx.Id, reason); - - if (result.Success) + var success = await LinkingApi.DeleteLearnedRuleAsync(ruleId); + if (success) { - // Remove from list - var list = RemainingUnlinked.ToList(); - list.Remove(tx); - RemainingUnlinked = list; + LearnedRules.RemoveAll(r => r.Id == ruleId); StateHasChanged(); } } catch (Exception ex) { - Console.WriteLine($"Error marking as external: {ex.Message}"); + ErrorMessage = $"Fehler beim Löschen: {ex.Message}"; + StateHasChanged(); + } + } + + private void ToggleRulesExpanded() + { + RulesExpanded = !RulesExpanded; + } + + private void AddEvent(string type, string message) + { + EventLog.Add(new EventLogEntry + { + Type = type, + Message = message, + Timestamp = DateTime.Now + }); + + // Keep only last 100 events + if (EventLog.Count > 100) + { + EventLog.RemoveAt(0); + } + } + + private async Task ScrollEventLogToBottom() + { + try + { + // This would require JS interop - for now we skip it + await Task.CompletedTask; + } + catch + { + // Ignore } } + private string GetOptionButtonClass(string option) + { + return option.ToLowerInvariant() switch + { + var o when o.Contains("extern") || o.Contains("ja") => "btn-success", + var o when o.Contains("skip") || o.Contains("überspringen") => "btn-secondary", + var o when o.Contains("nein") => "btn-outline-secondary", + _ => "btn-primary" + }; + } + private async Task Complete() { await OnComplete.InvokeAsync(); } + + public async ValueTask DisposeAsync() + { + if (hubConnection != null) + { + if (SessionId != null) + { + try + { + await hubConnection.InvokeAsync("LeaveSession", SessionId); + } + catch + { + // Ignore + } + } + await hubConnection.DisposeAsync(); + } + } + + private class EventLogEntry + { + public string Type { get; set; } = ""; + public string Message { get; set; } = ""; + public DateTime Timestamp { get; set; } + + public string Icon => Type switch + { + "linked" => "✓", + "external" => "⊕", + "skipped" => "⏭", + "error" => "⚠", + "rule" => "📝", + "start" => "▶", + "stop" => "⏹", + "complete" => "🎉", + _ => "•" + }; + + public string CssClass => Type switch + { + "linked" => "linked", + "external" => "external", + "skipped" => "skipped", + "error" => "error", + "rule" => "rule", + _ => "" + }; + } } diff --git a/src/CryptoTracker.Client/Shared/LotDTOs.cs b/src/CryptoTracker.Client/Shared/LotDTOs.cs index f9c7da7..24b023d 100644 --- a/src/CryptoTracker.Client/Shared/LotDTOs.cs +++ b/src/CryptoTracker.Client/Shared/LotDTOs.cs @@ -104,6 +104,7 @@ public record PendingLotAssignmentDTO( string Symbol, decimal Quantity, string WalletName, + int WalletId, string? Direction, // "Receive" für Transactions, "Sell" für Trades string? OppositeWalletName); diff --git a/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs b/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs index 31d812a..6c4bc79 100644 --- a/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs +++ b/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs @@ -129,3 +129,86 @@ public record ChatMessageDTO public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; public bool IsLoading { get; init; } } + +/// +/// Hint für potentielle Verknüpfung (vorberechnet) +/// +public record LinkingHintDTO +{ + public int SendId { get; init; } + public int ReceiveId { get; init; } + public decimal ConfidenceScore { get; init; } + public string Reason { get; init; } = ""; + public TimeSpan TimeDifference { get; init; } + public decimal AmountDifference { get; init; } +} + +/// +/// Context für den Agent mit allen Daten +/// +public record LinkingContextDTO +{ + public IList UnlinkedTransactions { get; init; } = []; + public IList Hints { get; init; } = []; + public IList LearnedRules { get; init; } = []; + public LinkingStatisticsDTO Statistics { get; init; } = new(); +} + +/// +/// Gelernte Regel für zukünftige Entscheidungen +/// +public record LearnedRuleDTO +{ + public string Id { get; init; } = ""; + public string RuleType { get; init; } = ""; // "skip_pattern", "link_pattern", "wallet_alias" + public string Pattern { get; init; } = ""; + public string Action { get; init; } = ""; // "mark_external", "link", "skip" + public string Description { get; init; } = ""; + public int TimesApplied { get; init; } + public DateTimeOffset CreatedAt { get; init; } +} + +/// +/// Live-Event vom Agent +/// +public record LinkingEventDTO +{ + public string EventType { get; init; } = ""; // "linked", "marked_external", "question", "progress", "error", "rule_learned" + public string Message { get; init; } = ""; + public int? SendId { get; init; } + public int? ReceiveId { get; init; } + public UnlinkedTransactionDTO? Transaction { get; init; } + public string? QuestionId { get; init; } + public IList? Options { get; init; } + public int ProcessedCount { get; init; } + public int TotalCount { get; init; } +} + +/// +/// Antwort vom User auf Agent-Frage +/// +public record UserResponseDTO +{ + public string QuestionId { get; init; } = ""; + public string Response { get; init; } = ""; + public bool ShouldRemember { get; init; } = true; +} + +/// +/// Session-State für interaktives Linking +/// +public record InteractiveLinkingSessionDTO +{ + public string SessionId { get; init; } = ""; + public bool IsActive { get; init; } + public int ProcessedCount { get; init; } + public int TotalCount { get; init; } + public int LinkedCount { get; init; } + public int MarkedExternalCount { get; init; } + public int SkippedCount { get; init; } + public string? CurrentQuestionId { get; init; } + public string? CurrentQuestion { get; init; } + public UnlinkedTransactionDTO? CurrentTransaction { get; init; } + public IList? CurrentOptions { get; init; } +} + diff --git a/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs b/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs new file mode 100644 index 0000000..2ad6445 --- /dev/null +++ b/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs @@ -0,0 +1,840 @@ +using System.Collections.Concurrent; +using CryptoTracker.Entities; +using CryptoTracker.Hubs; +using CryptoTracker.Shared; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace CryptoTracker.Agent.Services; + +/// +/// Service für interaktives KI-gestütztes Linking mit Human-in-the-Loop +/// +public class InteractiveLinkingService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + + // Aktive Sessions + private readonly ConcurrentDictionary _sessions = new(); + + public InteractiveLinkingService( + IServiceScopeFactory scopeFactory, + IHubContext hubContext, + ILogger logger) + { + _scopeFactory = scopeFactory; + _hubContext = hubContext; + _logger = logger; + } + + /// + /// Startet eine neue interaktive Linking-Session + /// + public async Task StartSessionAsync(CancellationToken ct = default) + { + var sessionId = Guid.NewGuid().ToString("N"); + var session = new InteractiveLinkingSession + { + SessionId = sessionId, + StartedAt = DateTimeOffset.UtcNow, + UserResponseChannel = new AsyncQueue() + }; + + _sessions[sessionId] = session; + + // Starte den Linking-Prozess im Hintergrund + _ = Task.Run(() => RunLinkingProcessAsync(session, ct), ct); + + return session.ToDTO(); + } + + /// + /// User gibt Antwort auf Agent-Frage + /// + public async Task SubmitUserResponseAsync(string sessionId, UserResponseDTO response) + { + if (_sessions.TryGetValue(sessionId, out var session)) + { + await session.UserResponseChannel.EnqueueAsync(response); + } + } + + /// + /// Stoppt eine Session + /// + public void StopSession(string sessionId) + { + if (_sessions.TryRemove(sessionId, out var session)) + { + session.CancellationSource.Cancel(); + } + } + + /// + /// Gibt den aktuellen Session-Status zurück + /// + public InteractiveLinkingSessionDTO? GetSessionStatus(string sessionId) + { + return _sessions.TryGetValue(sessionId, out var session) ? session.ToDTO() : null; + } + + /// + /// Hauptprozess für interaktives Linking + /// + private async Task RunLinkingProcessAsync(InteractiveLinkingSession session, CancellationToken ct) + { + var linkedCt = CancellationTokenSource.CreateLinkedTokenSource(ct, session.CancellationSource.Token); + + try + { + using var scope = _scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // 1. Lade alle Daten und berechne Hints + var context = await BuildLinkingContextAsync(dbContext, linkedCt.Token); + session.TotalCount = context.UnlinkedTransactions.Count; + + await SendSessionUpdateAsync(session); + + // 2. Verarbeite jede Transaktion + var transactions = context.UnlinkedTransactions.ToList(); + var hintsLookup = BuildHintsLookup(context.Hints); + + // Lokale Liste von Regeln, die während der Session aktualisiert wird + var activeRules = context.LearnedRules.ToList(); + + foreach (var tx in transactions) + { + if (linkedCt.Token.IsCancellationRequested) + break; + + session.CurrentTransaction = tx; + session.ProcessedCount++; + + // Prüfe ob gelernte Regel zutrifft (mit aktueller Liste!) + var matchingRule = FindMatchingRule(tx, activeRules); + if (matchingRule != null) + { + await ApplyRuleAsync(dbContext, session, tx, matchingRule, linkedCt.Token); + + // Update usage count in active rules + var ruleToUpdate = activeRules.FirstOrDefault(r => r.Id == matchingRule.Id); + if (ruleToUpdate != null) + { + var index = activeRules.IndexOf(ruleToUpdate); + activeRules[index] = ruleToUpdate with { TimesApplied = ruleToUpdate.TimesApplied + 1 }; + } + continue; + } + + // Prüfe ob Hint mit hoher Konfidenz vorhanden + var hints = hintsLookup.GetValueOrDefault(tx.Id, []); + var highConfidenceHint = hints.FirstOrDefault(h => h.ConfidenceScore >= 0.9m); + + if (highConfidenceHint != null) + { + // Automatisch verknüpfen + var otherTx = transactions.FirstOrDefault(t => + t.Id == (tx.IsSend ? highConfidenceHint.ReceiveId : highConfidenceHint.SendId)); + + if (otherTx != null) + { + await LinkTransactionsAsync(dbContext, session, tx, otherTx, highConfidenceHint.Reason, linkedCt.Token); + continue; + } + } + + // Frage den User (mit allen Transaktionen für Kontext) + var question = BuildQuestion(tx, hints, transactions, activeRules); + await AskUserAsync(session, question, linkedCt.Token); + + // Warte auf Antwort + var response = await session.UserResponseChannel.DequeueAsync(linkedCt.Token); + + // Verarbeite Antwort und erhalte ggf. neue Regel zurück + var newRule = await ProcessUserResponseAsync(dbContext, session, tx, hints, response, linkedCt.Token); + + // Wenn eine neue Regel gelernt wurde, zur aktiven Liste hinzufügen + if (newRule != null) + { + activeRules.Add(newRule); + _logger.LogInformation("Neue Regel zur aktiven Liste hinzugefügt: {Pattern} → {Action}", + newRule.Pattern, newRule.Action); + } + } + + // 3. Session abschließen + session.IsActive = false; + var finalStats = await GetStatisticsAsync(dbContext, linkedCt.Token); + + await _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnSessionCompleted", finalStats, linkedCt.Token); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Session {SessionId} wurde abgebrochen", session.SessionId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler in Session {SessionId}", session.SessionId); + await _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnError", ex.Message, CancellationToken.None); + } + finally + { + _sessions.TryRemove(session.SessionId, out _); + } + } + + /// + /// Baut den Linking-Context mit allen Daten und Hints + /// + private async Task BuildLinkingContextAsync(CryptoTrackerDbContext dbContext, CancellationToken ct) + { + // Lade unverknüpfte Transaktionen + var unlinked = await dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked) + .OrderBy(t => t.DateTime) + .Select(t => new UnlinkedTransactionDTO + { + Id = t.Id, + DateTime = t.DateTime, + Type = t.TransactionType.ToString(), + Symbol = t.Symbol, + Quantity = t.Quantity, + QuantityAfterFee = t.QuantityAfterFee, + Comment = t.Comment, + Address = t.Address, + WalletName = t.Wallet.Name, + TransactionId = t.TransactionId, + Network = t.Network + }) + .ToListAsync(ct); + + // Berechne Hints (ähnlich wie ProcessTransactionPairs) + var hints = CalculateHints(unlinked); + + // Lade gelernte Regeln + var rules = await LoadLearnedRulesAsync(dbContext, ct); + + // Statistiken + var stats = await GetStatisticsAsync(dbContext, ct); + + return new LinkingContextDTO + { + UnlinkedTransactions = unlinked, + Hints = hints, + LearnedRules = rules, + Statistics = stats + }; + } + + /// + /// Berechnet potentielle Verknüpfungs-Hints basierend auf Zeit und Betrag + /// + private List CalculateHints(IList transactions) + { + var hints = new List(); + var sends = transactions.Where(t => t.IsSend).ToList(); + var receives = transactions.Where(t => t.IsReceive).ToList(); + + foreach (var send in sends) + { + foreach (var receive in receives) + { + // Muss gleiches Symbol sein + if (!string.Equals(send.Symbol, receive.Symbol, StringComparison.OrdinalIgnoreCase)) + continue; + + // Zeit-Differenz (max 24 Stunden) + var timeDiff = receive.DateTime - send.DateTime; + if (timeDiff < TimeSpan.FromMinutes(-5) || timeDiff > TimeSpan.FromHours(24)) + continue; + + // Betrags-Differenz + var amountDiff = Math.Abs(send.QuantityAfterFee - receive.Quantity); + var amountPercent = send.QuantityAfterFee > 0 + ? amountDiff / send.QuantityAfterFee + : 1; + + // Berechne Konfidenz + var confidence = CalculateConfidence(send, receive, timeDiff, amountPercent); + + if (confidence >= 0.5m) + { + hints.Add(new LinkingHintDTO + { + SendId = send.Id, + ReceiveId = receive.Id, + ConfidenceScore = confidence, + Reason = BuildHintReason(send, receive, timeDiff, amountDiff), + TimeDifference = timeDiff, + AmountDifference = amountDiff + }); + } + } + } + + return hints.OrderByDescending(h => h.ConfidenceScore).ToList(); + } + + private decimal CalculateConfidence( + UnlinkedTransactionDTO send, + UnlinkedTransactionDTO receive, + TimeSpan timeDiff, + decimal amountPercentDiff) + { + var score = 0.5m; // Basis (gleiches Symbol) + + // Zeit-Score (0-0.25) + if (timeDiff.TotalMinutes <= 5) score += 0.25m; + else if (timeDiff.TotalMinutes <= 30) score += 0.20m; + else if (timeDiff.TotalHours <= 1) score += 0.15m; + else if (timeDiff.TotalHours <= 6) score += 0.10m; + else score += 0.05m; + + // Betrags-Score (0-0.25) + if (amountPercentDiff == 0) score += 0.25m; + else if (amountPercentDiff < 0.001m) score += 0.20m; + else if (amountPercentDiff < 0.01m) score += 0.15m; + else if (amountPercentDiff < 0.05m) score += 0.10m; + else score += 0.05m; + + // Kommentar-Hinweise + var sendComment = send.Comment?.ToLowerInvariant() ?? ""; + var receiveComment = receive.Comment?.ToLowerInvariant() ?? ""; + var walletNames = new[] { "ledger", "metamask", "binance", "kraken", "coinbase", "exodus", "trust" }; + + if (walletNames.Any(w => sendComment.Contains(w) || receiveComment.Contains(w))) + score += 0.05m; + + // Gleiche Adresse/TransactionId-Muster + if (!string.IsNullOrEmpty(send.Address) && !string.IsNullOrEmpty(receive.Address)) + { + // Netzwerk-gleich könnte ein Hinweis sein + if (string.Equals(send.Network, receive.Network, StringComparison.OrdinalIgnoreCase)) + score += 0.02m; + } + + return Math.Min(1.0m, score); + } + + private string BuildHintReason( + UnlinkedTransactionDTO send, + UnlinkedTransactionDTO receive, + TimeSpan timeDiff, + decimal amountDiff) + { + var reasons = new List(); + + if (timeDiff.TotalMinutes <= 5) + reasons.Add("Zeit < 5 Min"); + else if (timeDiff.TotalMinutes <= 30) + reasons.Add($"Zeit: {timeDiff.TotalMinutes:F0} Min"); + else + reasons.Add($"Zeit: {timeDiff.TotalHours:F1}h"); + + if (amountDiff == 0) + reasons.Add("Betrag exakt"); + else + reasons.Add($"Δ {amountDiff:F8} {send.Symbol}"); + + reasons.Add($"{send.WalletName} → {receive.WalletName}"); + + return string.Join(", ", reasons); + } + + private Dictionary> BuildHintsLookup(IList hints) + { + var lookup = new Dictionary>(); + + foreach (var hint in hints) + { + if (!lookup.ContainsKey(hint.SendId)) + lookup[hint.SendId] = []; + lookup[hint.SendId].Add(hint); + + if (!lookup.ContainsKey(hint.ReceiveId)) + lookup[hint.ReceiveId] = []; + lookup[hint.ReceiveId].Add(hint); + } + + return lookup; + } + + private LearnedRuleDTO? FindMatchingRule(UnlinkedTransactionDTO tx, IList rules) + { + foreach (var rule in rules) + { + var matches = rule.RuleType switch + { + "comment_pattern" => !string.IsNullOrEmpty(tx.Comment) && + tx.Comment.Contains(rule.Pattern, StringComparison.OrdinalIgnoreCase), + "wallet_pattern" => tx.WalletName.Contains(rule.Pattern, StringComparison.OrdinalIgnoreCase), + "symbol_pattern" => tx.Symbol.Equals(rule.Pattern, StringComparison.OrdinalIgnoreCase), + _ => false + }; + + if (matches) + return rule; + } + + return null; + } + + private async Task ApplyRuleAsync( + CryptoTrackerDbContext dbContext, + InteractiveLinkingSession session, + UnlinkedTransactionDTO tx, + LearnedRuleDTO rule, + CancellationToken ct) + { + if (rule.Action == "mark_external") + { + await MarkAsExternalAsync(dbContext, session, tx, rule.Description, ct); + } + // Weitere Aktionen können hier hinzugefügt werden + } + + private async Task LinkTransactionsAsync( + CryptoTrackerDbContext dbContext, + InteractiveLinkingSession session, + UnlinkedTransactionDTO tx1, + UnlinkedTransactionDTO tx2, + string reason, + CancellationToken ct) + { + var send = tx1.IsSend ? tx1 : tx2; + var receive = tx1.IsReceive ? tx1 : tx2; + + var sendEntity = await dbContext.CryptoTransactions.FindAsync([send.Id], ct); + var receiveEntity = await dbContext.CryptoTransactions.FindAsync([receive.Id], ct); + + if (sendEntity == null || receiveEntity == null) + return; + + sendEntity.OppositeTransactionId = receive.Id; + sendEntity.OppositeWalletId = receiveEntity.WalletId; + receiveEntity.OppositeTransactionId = send.Id; + receiveEntity.OppositeWalletId = sendEntity.WalletId; + + var metadata = new TransactionLinkMetadata + { + TransactionId = send.Id, + LinkType = TransactionLinkType.AIAssisted, + Confidence = 0.9m, + Reason = reason, + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = true, + ConfirmedAt = DateTimeOffset.UtcNow + }; + dbContext.TransactionLinkMetadata.Add(metadata); + + await dbContext.SaveChangesAsync(ct); + + session.LinkedCount++; + + await SendEventAsync(session, new LinkingEventDTO + { + EventType = "linked", + Message = $"Verknüpft: {send.Symbol} {send.Quantity:F8} ({send.WalletName} → {receive.WalletName})", + SendId = send.Id, + ReceiveId = receive.Id, + ProcessedCount = session.ProcessedCount, + TotalCount = session.TotalCount + }); + } + + private async Task MarkAsExternalAsync( + CryptoTrackerDbContext dbContext, + InteractiveLinkingSession session, + UnlinkedTransactionDTO tx, + string reason, + CancellationToken ct) + { + var entity = await dbContext.CryptoTransactions.FindAsync([tx.Id], ct); + if (entity == null) + return; + + entity.IsIntentionallyUnlinked = true; + + var metadata = new TransactionLinkMetadata + { + TransactionId = tx.Id, + LinkType = TransactionLinkType.IntentionallyUnlinked | TransactionLinkType.AIAssisted, + Confidence = 1.0m, + Reason = reason, + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = true, + ConfirmedAt = DateTimeOffset.UtcNow + }; + dbContext.TransactionLinkMetadata.Add(metadata); + + await dbContext.SaveChangesAsync(ct); + + session.MarkedExternalCount++; + + await SendEventAsync(session, new LinkingEventDTO + { + EventType = "marked_external", + Message = $"Extern: {tx.Symbol} {tx.Quantity:F8} - {reason}", + Transaction = tx, + ProcessedCount = session.ProcessedCount, + TotalCount = session.TotalCount + }); + } + + private LinkingQuestionDTO BuildQuestion( + UnlinkedTransactionDTO tx, + List hints, + List allTransactions, + IList rules) + { + var options = new List(); + var optionToHintIndex = new Dictionary(); + var message = new System.Text.StringBuilder(); + + message.AppendLine($"**{(tx.IsSend ? "Send" : "Receive")}:** {tx.Symbol} {tx.Quantity:F8}"); + message.AppendLine($"**Wallet:** {tx.WalletName}"); + message.AppendLine($"**Zeit:** {tx.DateTime:dd.MM.yyyy HH:mm}"); + + if (!string.IsNullOrEmpty(tx.Comment)) + message.AppendLine($"**Kommentar:** {tx.Comment}"); + + if (hints.Count > 0) + { + message.AppendLine(); + message.AppendLine("**Mögliche Verknüpfungen:**"); + + var hintIndex = 0; + foreach (var hint in hints.Take(3)) + { + var matchId = tx.IsSend ? hint.ReceiveId : hint.SendId; + var matchTx = allTransactions.FirstOrDefault(t => t.Id == matchId); + + string optionText; + if (matchTx != null) + { + // Zeige detaillierte Info über den Match-Kandidaten + var timeDiffText = FormatTimeDifference(hint.TimeDifference); + var amountDiffText = hint.AmountDifference == 0 + ? "exakt" + : $"Δ {hint.AmountDifference:F8}"; + + message.AppendLine($" {hintIndex + 1}. {matchTx.WalletName} | {matchTx.DateTime:dd.MM.yy HH:mm} | {matchTx.Quantity:F8} {matchTx.Symbol} | {timeDiffText}, {amountDiffText} ({hint.ConfidenceScore:P0})"); + + // Option mit lesbarem Text + optionText = $"→ {matchTx.WalletName} ({matchTx.DateTime:dd.MM.yy HH:mm})"; + } + else + { + optionText = $"Verknüpfen mit #{matchId} ({hint.ConfidenceScore:P0})"; + } + + options.Add(optionText); + optionToHintIndex[optionText] = hintIndex; + hintIndex++; + } + } + + options.Add("Als externe Einnahme/Ausgabe markieren"); + options.Add("Überspringen (später bearbeiten)"); + + return new LinkingQuestionDTO + { + QuestionId = Guid.NewGuid().ToString("N"), + Message = message.ToString(), + Options = options, + Transaction = tx, + Hints = hints.Take(3).ToList(), + OptionToHintIndex = optionToHintIndex + }; + } + + private static string FormatTimeDifference(TimeSpan diff) + { + var absDiff = diff.Duration(); + if (absDiff.TotalMinutes < 1) return "< 1 Min"; + if (absDiff.TotalMinutes < 60) return $"{absDiff.TotalMinutes:F0} Min"; + if (absDiff.TotalHours < 24) return $"{absDiff.TotalHours:F1}h"; + return $"{absDiff.TotalDays:F1} Tage"; + } + + private async Task AskUserAsync(InteractiveLinkingSession session, LinkingQuestionDTO question, CancellationToken ct) + { + session.CurrentQuestionId = question.QuestionId; + session.CurrentQuestion = question.Message; + session.CurrentOptions = question.Options; + session.CurrentHints = question.Hints; + session.OptionToHintIndex = question.OptionToHintIndex; + + await SendEventAsync(session, new LinkingEventDTO + { + EventType = "question", + Message = question.Message, + Transaction = question.Transaction, + QuestionId = question.QuestionId, + Options = question.Options, + ProcessedCount = session.ProcessedCount, + TotalCount = session.TotalCount + }); + } + + /// + /// Verarbeitet die User-Antwort und gibt ggf. eine neu gelernte Regel zurück + /// + private async Task ProcessUserResponseAsync( + CryptoTrackerDbContext dbContext, + InteractiveLinkingSession session, + UnlinkedTransactionDTO tx, + List hints, + UserResponseDTO response, + CancellationToken ct) + { + var responseText = response.Response; + var responseTextLower = responseText.ToLowerInvariant(); + + // Option: Verknüpfen - prüfe ob die Response im OptionToHintIndex-Mapping ist + if (session.OptionToHintIndex != null && session.OptionToHintIndex.TryGetValue(responseText, out var hintIndex)) + { + if (hintIndex < hints.Count) + { + var hint = hints[hintIndex]; + var otherId = tx.IsSend ? hint.ReceiveId : hint.SendId; + var otherTx = await GetTransactionDTOAsync(dbContext, otherId, ct); + + if (otherTx != null) + { + await LinkTransactionsAsync(dbContext, session, tx, otherTx, + $"Manuell verknüpft: {hint.Reason}", ct); + return null; + } + } + } + + // Fallback: Verknüpfen (beginnt mit "→" oder "Verknüpfen") + if ((responseText.StartsWith("→") || responseTextLower.StartsWith("verknüpfen")) && hints.Count > 0) + { + // Nimm den ersten Hint als Fallback + var hint = hints[0]; + var otherId = tx.IsSend ? hint.ReceiveId : hint.SendId; + var otherTx = await GetTransactionDTOAsync(dbContext, otherId, ct); + + if (otherTx != null) + { + await LinkTransactionsAsync(dbContext, session, tx, otherTx, + $"Manuell verknüpft: {hint.Reason}", ct); + return null; + } + } + + // Option: Externe Einnahme/Ausgabe + if (responseTextLower.Contains("extern")) + { + var reason = "Vom Benutzer als extern markiert"; + LearnedRuleDTO? newRule = null; + + // Prüfe ob Regel gelernt werden soll + if (response.ShouldRemember && !string.IsNullOrEmpty(tx.Comment)) + { + newRule = new LearnedRuleDTO + { + Id = Guid.NewGuid().ToString("N"), + RuleType = "comment_pattern", + Pattern = tx.Comment, + Action = "mark_external", + Description = $"Kommentar '{tx.Comment}' → Externe Einnahme", + CreatedAt = DateTimeOffset.UtcNow, + TimesApplied = 0 + }; + + await SaveLearnedRuleAsync(dbContext, newRule, ct); + + reason = $"Extern (Regel gelernt: '{tx.Comment}')"; + + await SendEventAsync(session, new LinkingEventDTO + { + EventType = "rule_learned", + Message = $"Regel gelernt: Kommentar '{tx.Comment}' → Externe Einnahme" + }); + } + + await MarkAsExternalAsync(dbContext, session, tx, reason, ct); + return newRule; + } + + // Option: Überspringen (default) + session.SkippedCount++; + await SendEventAsync(session, new LinkingEventDTO + { + EventType = "skipped", + Message = $"Übersprungen: {tx.Symbol} {tx.Quantity:F8}", + Transaction = tx, + ProcessedCount = session.ProcessedCount, + TotalCount = session.TotalCount + }); + + return null; + } + + private async Task GetTransactionDTOAsync( + CryptoTrackerDbContext dbContext, + int id, + CancellationToken ct) + { + return await dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.Id == id) + .Select(t => new UnlinkedTransactionDTO + { + Id = t.Id, + DateTime = t.DateTime, + Type = t.TransactionType.ToString(), + Symbol = t.Symbol, + Quantity = t.Quantity, + QuantityAfterFee = t.QuantityAfterFee, + Comment = t.Comment, + Address = t.Address, + WalletName = t.Wallet.Name, + TransactionId = t.TransactionId, + Network = t.Network + }) + .FirstOrDefaultAsync(ct); + } + + private async Task> LoadLearnedRulesAsync( + CryptoTrackerDbContext dbContext, + CancellationToken ct) + { + // Für jetzt: Lade aus AgentMemory + var memory = await dbContext.AgentMemories + .Where(m => m.AgentKey == "transaction-linking" && m.Key.StartsWith("rule:")) + .ToListAsync(ct); + + return memory.Select(m => System.Text.Json.JsonSerializer.Deserialize(m.Value)) + .Where(r => r != null) + .Cast() + .ToList(); + } + + private async Task SaveLearnedRuleAsync( + CryptoTrackerDbContext dbContext, + LearnedRuleDTO rule, + CancellationToken ct) + { + var memory = new AgentMemory + { + AgentKey = "transaction-linking", + MemoryType = AgentMemoryType.UserDecision, + Key = $"rule:{rule.Id}", + Value = System.Text.Json.JsonSerializer.Serialize(rule), + Description = rule.Description, + CreatedAt = DateTimeOffset.UtcNow + }; + + dbContext.AgentMemories.Add(memory); + await dbContext.SaveChangesAsync(ct); + } + + private async Task GetStatisticsAsync(CryptoTrackerDbContext dbContext, CancellationToken ct) + { + var total = await dbContext.CryptoTransactions.CountAsync(ct); + var linked = await dbContext.CryptoTransactions.CountAsync(t => t.OppositeTransactionId != null, ct); + var external = await dbContext.CryptoTransactions.CountAsync(t => t.IsIntentionallyUnlinked, ct); + var unlinked = await dbContext.CryptoTransactions.CountAsync(t => + t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked, ct); + + return new LinkingStatisticsDTO + { + TotalTransactions = total, + LinkedTransactions = linked, + IntentionallyUnlinked = external, + UnlinkedTransactions = unlinked + }; + } + + private async Task SendEventAsync(InteractiveLinkingSession session, LinkingEventDTO evt) + { + await _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnLinkingEvent", evt); + } + + private async Task SendSessionUpdateAsync(InteractiveLinkingSession session) + { + await _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnSessionUpdate", session.ToDTO()); + } +} + +/// +/// Interne Session-Klasse +/// +internal class InteractiveLinkingSession +{ + public string SessionId { get; init; } = ""; + public DateTimeOffset StartedAt { get; init; } + public bool IsActive { get; set; } = true; + public int ProcessedCount { get; set; } + public int TotalCount { get; set; } + public int LinkedCount { get; set; } + public int MarkedExternalCount { get; set; } + public int SkippedCount { get; set; } + public string? CurrentQuestionId { get; set; } + public string? CurrentQuestion { get; set; } + public UnlinkedTransactionDTO? CurrentTransaction { get; set; } + public IList? CurrentOptions { get; set; } + public IList? CurrentHints { get; set; } + public Dictionary? OptionToHintIndex { get; set; } + public CancellationTokenSource CancellationSource { get; } = new(); + public required AsyncQueue UserResponseChannel { get; init; } + + public InteractiveLinkingSessionDTO ToDTO() => new() + { + SessionId = SessionId, + IsActive = IsActive, + ProcessedCount = ProcessedCount, + TotalCount = TotalCount, + LinkedCount = LinkedCount, + MarkedExternalCount = MarkedExternalCount, + SkippedCount = SkippedCount, + CurrentQuestionId = CurrentQuestionId, + CurrentQuestion = CurrentQuestion, + CurrentTransaction = CurrentTransaction, + CurrentOptions = CurrentOptions + }; +} + +/// +/// DTO für Fragen an den User +/// +internal record LinkingQuestionDTO +{ + public string QuestionId { get; init; } = ""; + public string Message { get; init; } = ""; + public IList Options { get; init; } = []; + public UnlinkedTransactionDTO? Transaction { get; init; } + public IList Hints { get; init; } = []; + public Dictionary OptionToHintIndex { get; init; } = []; +} + +/// +/// Einfache async Queue +/// +internal class AsyncQueue +{ + private readonly System.Threading.Channels.Channel _channel = + System.Threading.Channels.Channel.CreateUnbounded(); + + public async Task EnqueueAsync(T item) + { + await _channel.Writer.WriteAsync(item); + } + + public async Task DequeueAsync(CancellationToken ct = default) + { + return await _channel.Reader.ReadAsync(ct); + } +} diff --git a/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs b/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs index 653955d..850d7be 100644 --- a/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs +++ b/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs @@ -110,60 +110,167 @@ public async Task ContinueSessionAsync( } /// - /// Führt automatische Verknüpfung durch (ohne Benutzerinteraktion) + /// Führt automatische Verknüpfung durch - OHNE AI-Aufrufe! + /// Verwendet regelbasierte Logik und vorberechnete Hints. /// public async Task RunAutomaticLinkingAsync(CancellationToken ct = default) { - if (!IsConfigured) + try { - return new AutoLinkResult + var startTime = DateTimeOffset.UtcNow; + var linkedCount = 0; + var markedUnlinkedCount = 0; + var summaryLines = new List(); + + // 1. Lade gelernte Regeln + var rules = await _dbContext.AgentMemories + .Where(m => m.AgentKey == "transaction-linking") + .ToListAsync(ct); + + var skipPatterns = rules + .Where(r => r.MemoryType == AgentMemoryType.SkipPattern) + .Select(r => r.Key.ToLowerInvariant()) + .ToHashSet(); + + // 2. Lade alle unverknüpften Transaktionen + var unlinked = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked) + .OrderBy(t => t.DateTime) + .ToListAsync(ct); + + if (unlinked.Count == 0) { - Summary = "AI-Service nicht konfiguriert.", - Success = false - }; - } + return new AutoLinkResult + { + LinkedCount = 0, + MarkedUnlinkedCount = 0, + RemainingUnlinkedCount = 0, + Summary = "Keine unverknüpften Transaktionen gefunden.", + Success = true + }; + } - try - { - var agent = _agentBuilder.BuildAgent(TransactionLinkingAgentDefinition.KEY); + var sends = unlinked.Where(t => t.TransactionType == TransactionType.Send).ToList(); + var receives = unlinked.Where(t => t.TransactionType == TransactionType.Receive).ToList(); - var startTime = DateTimeOffset.UtcNow; + // 3. Automatische Verknüpfung basierend auf Regeln + // Zuerst: Bekannte externe Einnahmen markieren + foreach (var tx in receives.ToList()) + { + var comment = tx.Comment?.ToLowerInvariant() ?? ""; + + // Check gegen bekannte externe Muster + var isExternal = IsKnownExternalTransaction(comment, skipPatterns); + + if (isExternal.matched) + { + tx.IsIntentionallyUnlinked = true; + + var metadata = new TransactionLinkMetadata + { + TransactionId = tx.Id, + LinkType = TransactionLinkType.IntentionallyUnlinked | TransactionLinkType.AIAssisted, + Confidence = 1.0m, + Reason = isExternal.reason, + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = true, + ConfirmedAt = DateTimeOffset.UtcNow + }; + _dbContext.TransactionLinkMetadata.Add(metadata); + + receives.Remove(tx); + markedUnlinkedCount++; + } + } - var result = await agent.RunAsync( - """ - Führe automatische Verknüpfung durch: - 1. Lade gespeicherte Regeln (get_memory) - 2. Lade alle unverknüpften Transaktionen - 3. Für jede Transaktion: - - Prüfe ob bekannte Skip-Patterns zutreffen → mark_intentionally_unlinked - - Suche passende Gegenstücke (find_matching_transactions) - - Bei Konfidenz >= 0.9: Verknüpfe automatisch (link_transactions) - 4. Gib mir eine detaillierte Zusammenfassung: - - Wie viele wurden verknüpft? - - Wie viele als externe Einnahme markiert? - - Wie viele bleiben unverknüpft? - - Welche brauchen manuelle Prüfung? - """, - cancellationToken: ct); + // 4. Verknüpfe Send/Receive Paare basierend auf Zeit + Betrag + var linkedPairs = new List<(CryptoTransaction send, CryptoTransaction receive, string reason)>(); + + foreach (var send in sends.ToList()) + { + // Finde passendes Receive + var bestMatch = receives + .Where(r => + r.Symbol == send.Symbol && + r.DateTime >= send.DateTime.AddMinutes(-5) && + r.DateTime <= send.DateTime.AddHours(24)) + .Select(r => new + { + Receive = r, + TimeDiff = r.DateTime - send.DateTime, + AmountDiff = Math.Abs(send.QuantityAfterFee - r.Quantity), + AmountPercent = send.QuantityAfterFee > 0 + ? Math.Abs(send.QuantityAfterFee - r.Quantity) / send.QuantityAfterFee + : 1m + }) + .Where(m => m.AmountPercent < 0.01m) // Max 1% Differenz + .OrderBy(m => m.AmountDiff) + .ThenBy(m => m.TimeDiff) + .FirstOrDefault(); + + if (bestMatch != null) + { + var reason = $"Auto-Link: {send.Symbol} {send.Quantity:F8}, " + + $"Zeit: {bestMatch.TimeDiff.TotalMinutes:F0} Min, " + + $"Δ: {bestMatch.AmountDiff:F8}"; + + linkedPairs.Add((send, bestMatch.Receive, reason)); + sends.Remove(send); + receives.Remove(bestMatch.Receive); + } + } + + // 5. Führe die Verknüpfungen durch + foreach (var (send, receive, reason) in linkedPairs) + { + send.OppositeTransactionId = receive.Id; + send.OppositeWalletId = receive.WalletId; + receive.OppositeTransactionId = send.Id; + receive.OppositeWalletId = send.WalletId; + + var metadata = new TransactionLinkMetadata + { + TransactionId = send.Id, + LinkType = TransactionLinkType.AIAssisted, + Confidence = 0.95m, + Reason = reason, + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = true, + ConfirmedAt = DateTimeOffset.UtcNow + }; + _dbContext.TransactionLinkMetadata.Add(metadata); + + linkedCount++; + } + + await _dbContext.SaveChangesAsync(ct); - // Zähle Ergebnisse - var linkedCount = await _dbContext.TransactionLinkMetadata - .CountAsync(m => m.LinkedAt > startTime && - !m.LinkType.HasFlag(TransactionLinkType.IntentionallyUnlinked), ct); + // 6. Statistiken berechnen + var remainingUnlinked = sends.Count + receives.Count; - var markedUnlinkedCount = await _dbContext.TransactionLinkMetadata - .CountAsync(m => m.LinkedAt > startTime && - m.LinkType.HasFlag(TransactionLinkType.IntentionallyUnlinked), ct); + // Zusammenfassung erstellen + summaryLines.Add($"✅ {linkedCount} Transaktionen automatisch verknüpft"); + if (markedUnlinkedCount > 0) + summaryLines.Add($"📥 {markedUnlinkedCount} als externe Einnahme markiert"); + if (remainingUnlinked > 0) + summaryLines.Add($"⏳ {remainingUnlinked} benötigen manuelle Prüfung"); - var remainingUnlinked = await _dbContext.CryptoTransactions - .CountAsync(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked, ct); + // Details zu verknüpften Paaren + if (linkedPairs.Count > 0) + { + var bySymbol = linkedPairs.GroupBy(p => p.send.Symbol) + .Select(g => $"{g.Key}: {g.Count()}") + .ToList(); + summaryLines.Add($"Verknüpft nach Symbol: {string.Join(", ", bySymbol)}"); + } return new AutoLinkResult { LinkedCount = linkedCount, MarkedUnlinkedCount = markedUnlinkedCount, RemainingUnlinkedCount = remainingUnlinked, - Summary = result.Text ?? "", + Summary = string.Join("\n", summaryLines), Success = true }; } @@ -178,6 +285,48 @@ 2. Lade alle unverknüpften Transaktionen } } + /// + /// Prüft ob eine Transaktion basierend auf Kommentar als externe Einnahme erkannt wird + /// + private (bool matched, string reason) IsKnownExternalTransaction(string comment, HashSet customPatterns) + { + if (string.IsNullOrWhiteSpace(comment)) + return (false, ""); + + // Eingebaute Muster für externe Einnahmen + var builtInPatterns = new Dictionary + { + { "staking", "Staking Rewards" }, + { "eth 2.0 staking", "ETH 2.0 Staking Rewards" }, + { "airdrop", "Airdrop" }, + { "bonus", "Bonus" }, + { "referral", "Referral Bonus" }, + { "mining", "Mining Rewards" }, + { "lending", "Lending Interest" }, + { "interest", "Interest" }, + { "cashback", "Cashback" }, + { "reward", "Reward" }, + { "div. käufe", "Diverse Käufe (Fiat)" }, + { "kauf", "Kauf (Fiat)" }, + }; + + foreach (var (pattern, reason) in builtInPatterns) + { + if (comment.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + return (true, reason); + } + + // Benutzerdefinierte Muster + foreach (var pattern in customPatterns) + { + if (comment.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + return (true, $"Benutzerdefiniert: {pattern}"); + } + + return (false, ""); + } + + /// /// Gibt Statistiken über unverknüpfte Transaktionen zurück /// diff --git a/src/CryptoTracker/Controllers/LotsController.cs b/src/CryptoTracker/Controllers/LotsController.cs index 0abceb9..5ad5ec6 100644 --- a/src/CryptoTracker/Controllers/LotsController.cs +++ b/src/CryptoTracker/Controllers/LotsController.cs @@ -220,6 +220,7 @@ public async Task> GetPendingLotAssignments() t.Symbol, t.Quantity, t.Wallet.Name, + t.WalletId, t.TransactionType.ToString(), t.OppositeWallet?.Name))); @@ -230,6 +231,7 @@ public async Task> GetPendingLotAssignments() t.Symbol, t.Quantity, t.Wallet.Name, + t.WalletId, t.TradeType.ToString(), null))); diff --git a/src/CryptoTracker/Controllers/TransactionLinkingController.cs b/src/CryptoTracker/Controllers/TransactionLinkingController.cs index fcf0005..be03d80 100644 --- a/src/CryptoTracker/Controllers/TransactionLinkingController.cs +++ b/src/CryptoTracker/Controllers/TransactionLinkingController.cs @@ -11,13 +11,16 @@ namespace CryptoTracker.Controllers; public class TransactionLinkingController : ControllerBase, ITransactionLinkingApi { private readonly TransactionLinkingService _linkingService; + private readonly InteractiveLinkingService _interactiveService; private readonly CryptoTrackerDbContext _dbContext; public TransactionLinkingController( TransactionLinkingService linkingService, + InteractiveLinkingService interactiveService, CryptoTrackerDbContext dbContext) { _linkingService = linkingService; + _interactiveService = interactiveService; _dbContext = dbContext; } @@ -283,4 +286,211 @@ public async Task MarkAsIntentionallyUnlinkedAsync([FromQuery] in await _dbContext.SaveChangesAsync(); return new LinkResultDTO { Success = true, Message = "Transaktion als externe Einnahme/Ausgabe markiert" }; } + + // === Neue interaktive Linking-Methoden === + + /// + /// Startet eine neue interaktive Linking-Session + /// + [HttpPost("interactive/start")] + public async Task StartInteractiveSessionAsync() + { + return await _interactiveService.StartSessionAsync(); + } + + /// + /// Gibt den Status einer interaktiven Session zurück + /// + [HttpGet("interactive/{sessionId}/status")] + public Task GetInteractiveSessionStatusAsync(string sessionId) + { + return Task.FromResult(_interactiveService.GetSessionStatus(sessionId)); + } + + /// + /// Sendet User-Antwort an interaktive Session + /// + [HttpPost("interactive/{sessionId}/respond")] + public async Task SubmitUserResponseAsync(string sessionId, [FromBody] UserResponseDTO response) + { + await _interactiveService.SubmitUserResponseAsync(sessionId, response); + } + + /// + /// Stoppt eine interaktive Session + /// + [HttpPost("interactive/{sessionId}/stop")] + public Task StopInteractiveSessionAsync(string sessionId) + { + _interactiveService.StopSession(sessionId); + return Task.CompletedTask; + } + + /// + /// Gibt den Linking-Context (alle Daten + Hints) zurück + /// + [HttpGet("context")] + public async Task GetLinkingContextAsync() + { + // Lade unverknüpfte Transaktionen + var unlinked = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked) + .OrderBy(t => t.DateTime) + .Select(t => new UnlinkedTransactionDTO + { + Id = t.Id, + DateTime = t.DateTime, + Type = t.TransactionType.ToString(), + Symbol = t.Symbol, + Quantity = t.Quantity, + QuantityAfterFee = t.QuantityAfterFee, + Comment = t.Comment, + Address = t.Address, + WalletName = t.Wallet.Name, + TransactionId = t.TransactionId, + Network = t.Network + }) + .ToListAsync(); + + // Berechne Hints + var hints = CalculateHints(unlinked); + + // Lade Regeln + var rules = await GetLearnedRulesAsync(); + + // Statistiken + var stats = await GetStatisticsAsync(); + + return new LinkingContextDTO + { + UnlinkedTransactions = unlinked, + Hints = hints, + LearnedRules = rules, + Statistics = stats + }; + } + + /// + /// Gibt gelernte Regeln zurück + /// + [HttpGet("rules")] + public async Task> GetLearnedRulesAsync() + { + var memories = await _dbContext.AgentMemories + .Where(m => m.AgentKey == "transaction-linking" && m.Key.StartsWith("rule:")) + .ToListAsync(); + + return memories + .Select(m => System.Text.Json.JsonSerializer.Deserialize(m.Value)) + .Where(r => r != null) + .Cast() + .ToList(); + } + + /// + /// Löscht eine gelernte Regel + /// + [HttpDelete("rules/{ruleId}")] + public async Task DeleteLearnedRuleAsync(string ruleId) + { + var memory = await _dbContext.AgentMemories + .FirstOrDefaultAsync(m => m.AgentKey == "transaction-linking" && m.Key == $"rule:{ruleId}"); + + if (memory == null) + return false; + + _dbContext.AgentMemories.Remove(memory); + await _dbContext.SaveChangesAsync(); + return true; + } + + /// + /// Berechnet potentielle Verknüpfungs-Hints + /// + private List CalculateHints(IList transactions) + { + var hints = new List(); + var sends = transactions.Where(t => t.IsSend).ToList(); + var receives = transactions.Where(t => t.IsReceive).ToList(); + + foreach (var send in sends) + { + foreach (var receive in receives) + { + if (!string.Equals(send.Symbol, receive.Symbol, StringComparison.OrdinalIgnoreCase)) + continue; + + var timeDiff = receive.DateTime - send.DateTime; + if (timeDiff < TimeSpan.FromMinutes(-5) || timeDiff > TimeSpan.FromHours(24)) + continue; + + var amountDiff = Math.Abs(send.QuantityAfterFee - receive.Quantity); + var amountPercent = send.QuantityAfterFee > 0 + ? amountDiff / send.QuantityAfterFee + : 1; + + var confidence = CalculateConfidence(timeDiff, amountPercent); + + if (confidence >= 0.5m) + { + hints.Add(new LinkingHintDTO + { + SendId = send.Id, + ReceiveId = receive.Id, + ConfidenceScore = confidence, + Reason = BuildHintReason(send, receive, timeDiff, amountDiff), + TimeDifference = timeDiff, + AmountDifference = amountDiff + }); + } + } + } + + return hints.OrderByDescending(h => h.ConfidenceScore).ToList(); + } + + private decimal CalculateConfidence(TimeSpan timeDiff, decimal amountPercentDiff) + { + var score = 0.5m; + + if (timeDiff.TotalMinutes <= 5) score += 0.25m; + else if (timeDiff.TotalMinutes <= 30) score += 0.20m; + else if (timeDiff.TotalHours <= 1) score += 0.15m; + else if (timeDiff.TotalHours <= 6) score += 0.10m; + else score += 0.05m; + + if (amountPercentDiff == 0) score += 0.25m; + else if (amountPercentDiff < 0.001m) score += 0.20m; + else if (amountPercentDiff < 0.01m) score += 0.15m; + else if (amountPercentDiff < 0.05m) score += 0.10m; + else score += 0.05m; + + return Math.Min(1.0m, score); + } + + private string BuildHintReason( + UnlinkedTransactionDTO send, + UnlinkedTransactionDTO receive, + TimeSpan timeDiff, + decimal amountDiff) + { + var reasons = new List(); + + if (timeDiff.TotalMinutes <= 5) + reasons.Add("Zeit < 5 Min"); + else if (timeDiff.TotalMinutes <= 30) + reasons.Add($"Zeit: {timeDiff.TotalMinutes:F0} Min"); + else + reasons.Add($"Zeit: {timeDiff.TotalHours:F1}h"); + + if (amountDiff == 0) + reasons.Add("Betrag exakt"); + else + reasons.Add($"Δ {amountDiff:F8} {send.Symbol}"); + + reasons.Add($"{send.WalletName} → {receive.WalletName}"); + + return string.Join(", ", reasons); + } } diff --git a/src/CryptoTracker/Hubs/LinkingHub.cs b/src/CryptoTracker/Hubs/LinkingHub.cs new file mode 100644 index 0000000..a026e76 --- /dev/null +++ b/src/CryptoTracker/Hubs/LinkingHub.cs @@ -0,0 +1,69 @@ +using CryptoTracker.Agent.Services; +using CryptoTracker.Shared; +using Microsoft.AspNetCore.SignalR; + +namespace CryptoTracker.Hubs; + +/// +/// SignalR Hub für Live-Updates während des interaktiven Linking-Prozesses +/// +public class LinkingHub : Hub +{ + private readonly InteractiveLinkingService _linkingService; + + public LinkingHub(InteractiveLinkingService linkingService) + { + _linkingService = linkingService; + } + + /// + /// Client verbindet sich mit einer Linking-Session + /// + public async Task JoinSession(string sessionId) + { + await Groups.AddToGroupAsync(Context.ConnectionId, sessionId); + } + + /// + /// Client verlässt eine Linking-Session + /// + public async Task LeaveSession(string sessionId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, sessionId); + } + + /// + /// User sendet Antwort auf Agent-Frage + /// + public async Task SendUserResponse(string sessionId, UserResponseDTO response) + { + // Weiterleiten an den InteractiveLinkingService + await _linkingService.SubmitUserResponseAsync(sessionId, response); + } +} + +/// +/// Interface für das Senden von Events an Clients +/// +public interface ILinkingHubClient +{ + /// + /// Sendet ein Linking-Event an alle Clients in der Session + /// + Task OnLinkingEvent(LinkingEventDTO linkingEvent); + + /// + /// Sendet Session-Status-Update + /// + Task OnSessionUpdate(InteractiveLinkingSessionDTO session); + + /// + /// Sendet Fehler + /// + Task OnError(string message); + + /// + /// Session beendet + /// + Task OnSessionCompleted(LinkingStatisticsDTO finalStats); +} diff --git a/src/CryptoTracker/Startup.cs b/src/CryptoTracker/Startup.cs index 80dc15f..240dabd 100644 --- a/src/CryptoTracker/Startup.cs +++ b/src/CryptoTracker/Startup.cs @@ -11,6 +11,7 @@ using CryptoTracker.Agent.Definitions; using CryptoTracker.Agent.Services; using CryptoTracker.Agent.Tools; +using CryptoTracker.Hubs; using Azure.AI.OpenAI; using Azure; using Azure.Identity; @@ -131,6 +132,10 @@ public void ConfigureServices(IServiceCollection services) // Agent Services services.AddScoped(); + services.AddSingleton(); + + // SignalR + services.AddSignalR(); services.AddScoped(); services.AddScoped(); @@ -176,6 +181,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) //.AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(Overview).Assembly); endpoints.MapControllers(); + endpoints.MapHub("/hubs/linking"); endpoints.MapDefaultControllerRoute(); endpoints.MapFallbackToFile("index.html"); }); From a0a60258ddee13f264591af2958b3ef258863237 Mon Sep 17 00:00:00 2001 From: Thomas Humer Date: Sun, 1 Feb 2026 00:27:24 +0100 Subject: [PATCH 5/7] Implement interactive lot linking service with user input handling and rule management --- .../Pages/LotsLinking.razor | 12 + .../Pages/LotsLinking.razor.cs | 32 + .../Pages/TransactionLinking.razor | 75 +- .../Pages/TransactionLinking.razor.cs | 149 +-- .../Shared/Api/ILotsApi.cs | 7 + .../Shared/LinkingWizard.razor | 49 + .../Shared/LinkingWizard.razor.cs | 24 + src/CryptoTracker.Client/Shared/LotDTOs.cs | 97 +- .../Shared/LotLinkingWizard.razor | 573 +++++++++ .../Shared/LotLinkingWizard.razor.cs | 444 +++++++ .../Services/InteractiveLotLinkingService.cs | 1112 +++++++++++++++++ .../Controllers/LotsController.cs | 80 +- src/CryptoTracker/Hubs/LinkingHub.cs | 18 +- src/CryptoTracker/Startup.cs | 1 + 14 files changed, 2466 insertions(+), 207 deletions(-) create mode 100644 src/CryptoTracker.Client/Shared/LotLinkingWizard.razor create mode 100644 src/CryptoTracker.Client/Shared/LotLinkingWizard.razor.cs create mode 100644 src/CryptoTracker/Agent/Services/InteractiveLotLinkingService.cs diff --git a/src/CryptoTracker.Client/Pages/LotsLinking.razor b/src/CryptoTracker.Client/Pages/LotsLinking.razor index 9100393..282a8f7 100644 --- a/src/CryptoTracker.Client/Pages/LotsLinking.razor +++ b/src/CryptoTracker.Client/Pages/LotsLinking.razor @@ -11,6 +11,9 @@

Verknüpfe Transaktionen mit ihren Lots für eine lückenlose Steuerdokumentation.

+
} + +@* Lot-Linking Wizard *@ +@if (IsWizardOpen) +{ + +} diff --git a/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs b/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs index 90dea5e..4053322 100644 --- a/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs +++ b/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs @@ -14,6 +14,10 @@ public partial class LotsLinking private string ActiveTab = "pending"; private string? SuccessMessage; + // Wizard State + private bool IsWizardOpen = false; + private LotLinkingStatisticsDTO? LotLinkingStatistics; + // Data private FlowStatisticsDTO? FlowStatistics; private IList PendingAssignments = new List(); @@ -93,6 +97,34 @@ private void SetActiveTab(string tab) ActiveTab = tab; } + // Wizard Methods + private async Task OpenWizard() + { + try + { + LotLinkingStatistics = await LotsApi.GetLotLinkingStatisticsAsync(); + } + catch + { + LotLinkingStatistics = null; + } + IsWizardOpen = true; + StateHasChanged(); + } + + private async Task OnWizardComplete() + { + IsWizardOpen = false; + await LoadDataAsync(); + SuccessMessage = "Lot-Zuordnung abgeschlossen"; + _ = HideSuccessMessage(); + } + + private void OnWizardCancel() + { + IsWizardOpen = false; + } + private async Task ValidateAllFlows() { IsValidating = true; diff --git a/src/CryptoTracker.Client/Pages/TransactionLinking.razor b/src/CryptoTracker.Client/Pages/TransactionLinking.razor index 842e191..acb81fa 100644 --- a/src/CryptoTracker.Client/Pages/TransactionLinking.razor +++ b/src/CryptoTracker.Client/Pages/TransactionLinking.razor @@ -20,26 +20,20 @@

Verknüpfe Send/Receive-Transaktionen für korrekte Steuerberechnung.

- + - @if (IsAIConfigured) - { - - - } @@ -100,49 +94,6 @@ } } -@* AI Chat Panel (aufklappbar) *@ -@if (IsChatOpen && IsAIConfigured) -{ -
-
-

AI-Assistent

- -
-
- @foreach (var msg in ChatMessages) - { -
- @if (msg.IsLoading) - { -
- - - -
- } - else - { -
@((MarkupString)FormatMarkdown(msg.Content))
-
@msg.Timestamp.ToLocalTime().ToString("HH:mm")
- } -
- } -
-
- - -
-
-} - @* Typ-Filter Tabs *@
} + @* Freitext-Option *@ + @if (!ShowFreeTextInput) + { + + } }
+ @* Freitext-Eingabe *@ + @if (ShowFreeTextInput) + { +
+ + +
+ + +
+
+ }
+ @if (ShowVirtualWalletSelection) + { +
+ + + @if (IsLoadingVirtualWallets) + { +
+ + Lade virtuelle Wallets... +
+ } + @if (!IsLoadingVirtualWallets && VirtualWallets.Count == 0) + { +
Noch keine virtuellen Wallets vorhanden.
+ } +
oder
+ + +
+ + + +
+
+ } @* Freitext-Eingabe *@ @if (ShowFreeTextInput) { @@ -424,6 +474,50 @@ gap: 8px; } + .virtual-wallet-section { + margin-top: 12px; + padding: 12px; + background: #f5f9ff; + border: 1px dashed #90caf9; + border-radius: 6px; + } + + .virtual-wallet-section label { + display: block; + margin: 6px 0 4px; + font-weight: 500; + } + + .virtual-wallet-divider { + margin: 10px 0; + font-size: 0.8rem; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .virtual-wallet-actions { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; + } + + .virtual-wallet-loading { + margin-top: 8px; + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: #666; + } + + .virtual-wallet-empty { + margin-top: 6px; + font-size: 0.85rem; + color: #888; + } + .linking-rules-section { border: 1px solid #ddd; border-radius: 8px; diff --git a/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs b/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs index 322f3e6..43c3e16 100644 --- a/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs +++ b/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs @@ -11,6 +11,7 @@ public partial class LinkingWizard : IAsyncDisposable [Parameter] public LinkingStatisticsDTO? Statistics { get; set; } [Inject] private ITransactionLinkingApi LinkingApi { get; set; } = default!; + [Inject] private IWalletApi WalletApi { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!; // State @@ -37,6 +38,11 @@ public partial class LinkingWizard : IAsyncDisposable private bool ShouldRemember = true; private bool ShowFreeTextInput = false; private string? FreeTextInput; + private bool ShowVirtualWalletSelection = false; + private bool IsLoadingVirtualWallets = false; + private IList VirtualWallets = new List(); + private int? SelectedVirtualWalletId; + private string? NewVirtualWalletName; // Learned rules private List LearnedRules = new(); @@ -49,6 +55,7 @@ public partial class LinkingWizard : IAsyncDisposable // Session private string? SessionId; private HubConnection? hubConnection; + private const string VirtualWalletOptionLabel = "Gegenstück in virtuelles Wallet buchen"; private int ProgressPercent => TotalCount > 0 ? (int)(ProcessedCount * 100.0 / TotalCount) : 0; @@ -168,6 +175,9 @@ private void HandleLinkingEvent(LinkingEventDTO evt) CurrentQuestion = evt.Message; CurrentTransaction = evt.Transaction; CurrentOptions = evt.Options; + ShowVirtualWalletSelection = false; + SelectedVirtualWalletId = null; + NewVirtualWalletName = null; IsProcessing = false; break; @@ -209,6 +219,9 @@ private void HandleSessionUpdate(InteractiveLinkingSessionDTO session) CurrentQuestion = session.CurrentQuestion; CurrentTransaction = session.CurrentTransaction; CurrentOptions = session.CurrentOptions; + ShowVirtualWalletSelection = false; + SelectedVirtualWalletId = null; + NewVirtualWalletName = null; IsProcessing = false; } @@ -233,27 +246,50 @@ private void HandleSessionCompleted(LinkingStatisticsDTO stats) IsCompleted = true; IsProcessing = false; CurrentQuestion = null; + ShowVirtualWalletSelection = false; AddEvent("complete", $"Fertig! {LinkedCount} verknüpft, {MarkedExternalCount} extern, {SkippedCount} übersprungen"); StateHasChanged(); }); } + private async Task OnOptionSelected(string option) + { + if (option == VirtualWalletOptionLabel) + { + ShowVirtualWalletSelection = true; + ShowFreeTextInput = false; + FreeTextInput = null; + await LoadVirtualWalletsAsync(); + StateHasChanged(); + return; + } + + await AnswerQuestion(option); + } + private async Task AnswerQuestion(string answer) { - if (CurrentQuestionId == null || SessionId == null) return; + if (CurrentQuestionId == null) return; + + var response = new UserResponseDTO + { + QuestionId = CurrentQuestionId, + Response = answer, + ShouldRemember = ShouldRemember + }; + + await SendResponseAsync(response); + } + + private async Task SendResponseAsync(UserResponseDTO response) + { + if (SessionId == null) return; IsAnswering = true; StateHasChanged(); try { - var response = new UserResponseDTO - { - QuestionId = CurrentQuestionId, - Response = answer, - ShouldRemember = ShouldRemember - }; - // Send via SignalR if (hubConnection?.State == HubConnectionState.Connected) { @@ -272,6 +308,9 @@ private async Task AnswerQuestion(string answer) CurrentOptions = null; ShowFreeTextInput = false; FreeTextInput = null; + ShowVirtualWalletSelection = false; + SelectedVirtualWalletId = null; + NewVirtualWalletName = null; IsProcessing = true; ProcessingMessage = "Verarbeite Antwort..."; } @@ -327,6 +366,9 @@ private void ShowFreeText() { ShowFreeTextInput = true; FreeTextInput = null; + ShowVirtualWalletSelection = false; + SelectedVirtualWalletId = null; + NewVirtualWalletName = null; } private void CancelFreeText() @@ -343,6 +385,76 @@ private async Task SubmitFreeText() await AnswerQuestion($"[Freitext] {FreeTextInput}"); } + private async Task LoadVirtualWalletsAsync() + { + if (IsLoadingVirtualWallets) + return; + + IsLoadingVirtualWallets = true; + try + { + var wallets = await WalletApi.GetVirtualWalletInfosAsync(); + VirtualWallets = wallets + .Where(w => w.IsVirtual) + .OrderBy(w => w.Name) + .ToList(); + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Laden der virtuellen Wallets: {ex.Message}"; + } + finally + { + IsLoadingVirtualWallets = false; + } + } + + private async Task LinkWithExistingVirtualWallet() + { + if (CurrentQuestionId == null || SelectedVirtualWalletId == null) + return; + + var response = new UserResponseDTO + { + QuestionId = CurrentQuestionId, + Response = VirtualWalletOptionLabel, + ShouldRemember = ShouldRemember, + Action = "virtual_wallet", + VirtualWalletId = SelectedVirtualWalletId + }; + + await SendResponseAsync(response); + } + + private async Task CreateAndLinkVirtualWallet() + { + if (CurrentQuestionId == null) + return; + + var walletName = NewVirtualWalletName?.Trim(); + if (string.IsNullOrWhiteSpace(walletName)) + return; + + var response = new UserResponseDTO + { + QuestionId = CurrentQuestionId, + Response = VirtualWalletOptionLabel, + ShouldRemember = ShouldRemember, + Action = "virtual_wallet", + VirtualWalletName = walletName + }; + + await SendResponseAsync(response); + await LoadVirtualWalletsAsync(); + } + + private void CancelVirtualWalletSelection() + { + ShowVirtualWalletSelection = false; + SelectedVirtualWalletId = null; + NewVirtualWalletName = null; + } + private void AddEvent(string type, string message) { EventLog.Add(new EventLogEntry @@ -377,6 +489,7 @@ private string GetOptionButtonClass(string option) return option.ToLowerInvariant() switch { var o when o.Contains("extern") || o.Contains("ja") => "btn-success", + var o when o.Contains("virtuell") => "btn-outline-primary", var o when o.Contains("skip") || o.Contains("überspringen") => "btn-secondary", var o when o.Contains("nein") => "btn-outline-secondary", _ => "btn-primary" diff --git a/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs b/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs index 6c4bc79..2de262d 100644 --- a/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs +++ b/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs @@ -192,6 +192,9 @@ public record UserResponseDTO public string QuestionId { get; init; } = ""; public string Response { get; init; } = ""; public bool ShouldRemember { get; init; } = true; + public string? Action { get; init; } + public int? VirtualWalletId { get; init; } + public string? VirtualWalletName { get; init; } } /// diff --git a/src/CryptoTracker.Client/Shared/WalletInfoDTO.cs b/src/CryptoTracker.Client/Shared/WalletInfoDTO.cs index 767bedc..25151ab 100644 --- a/src/CryptoTracker.Client/Shared/WalletInfoDTO.cs +++ b/src/CryptoTracker.Client/Shared/WalletInfoDTO.cs @@ -1,3 +1,3 @@ namespace CryptoTracker.Shared; -public record WalletInfoDTO(int Id, string Name); +public record WalletInfoDTO(int Id, string Name, bool IsVirtual = false); diff --git a/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs b/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs index 2ad6445..db6285b 100644 --- a/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs +++ b/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs @@ -15,6 +15,7 @@ public class InteractiveLinkingService private readonly IServiceScopeFactory _scopeFactory; private readonly IHubContext _hubContext; private readonly ILogger _logger; + private const string VirtualWalletAction = "virtual_wallet"; // Aktive Sessions private readonly ConcurrentDictionary _sessions = new(); @@ -540,6 +541,7 @@ private LinkingQuestionDTO BuildQuestion( } } + options.Add("Gegenstück in virtuelles Wallet buchen"); options.Add("Als externe Einnahme/Ausgabe markieren"); options.Add("Überspringen (später bearbeiten)"); @@ -597,6 +599,24 @@ private async Task AskUserAsync(InteractiveLinkingSession session, LinkingQuesti var responseText = response.Response; var responseTextLower = responseText.ToLowerInvariant(); + if (string.Equals(response.Action, VirtualWalletAction, StringComparison.OrdinalIgnoreCase)) + { + var error = await LinkToVirtualWalletAsync(dbContext, session, tx, response, ct); + if (!string.IsNullOrEmpty(error)) + { + session.SkippedCount++; + await SendEventAsync(session, new LinkingEventDTO + { + EventType = "skipped", + Message = $"Übersprungen: {error}", + Transaction = tx, + ProcessedCount = session.ProcessedCount, + TotalCount = session.TotalCount + }); + } + return null; + } + // Option: Verknüpfen - prüfe ob die Response im OptionToHintIndex-Mapping ist if (session.OptionToHintIndex != null && session.OptionToHintIndex.TryGetValue(responseText, out var hintIndex)) { @@ -680,6 +700,106 @@ await LinkTransactionsAsync(dbContext, session, tx, otherTx, return null; } + private async Task LinkToVirtualWalletAsync( + CryptoTrackerDbContext dbContext, + InteractiveLinkingSession session, + UnlinkedTransactionDTO tx, + UserResponseDTO response, + CancellationToken ct) + { + var virtualWallet = await ResolveVirtualWalletAsync(dbContext, response, ct); + if (virtualWallet == null) + { + return "Virtuelles Wallet konnte nicht gefunden oder erstellt werden."; + } + + var oppositeType = tx.IsSend ? TransactionType.Receive : TransactionType.Send; + var oppositeQuantity = tx.IsSend ? tx.QuantityAfterFee : tx.Quantity; + + if (oppositeQuantity <= 0) + { + return "Ungültige Menge für virtuelles Gegenstück."; + } + + var oppositeTransaction = new CryptoTransaction + { + WalletId = virtualWallet.Id, + DateTime = tx.DateTime, + TransactionType = oppositeType, + Symbol = tx.Symbol, + Quantity = oppositeQuantity, + Fee = 0m, + Comment = $"Virtuelles Gegenstück zu #{tx.Id} ({tx.WalletName})", + Address = tx.Address, + Network = tx.Network, + TransactionId = tx.TransactionId + }; + + dbContext.CryptoTransactions.Add(oppositeTransaction); + await dbContext.SaveChangesAsync(ct); + + var oppositeDto = new UnlinkedTransactionDTO + { + Id = oppositeTransaction.Id, + DateTime = oppositeTransaction.DateTime, + Type = oppositeType.ToString(), + Symbol = oppositeTransaction.Symbol, + Quantity = oppositeTransaction.Quantity, + QuantityAfterFee = oppositeTransaction.QuantityAfterFee, + Comment = oppositeTransaction.Comment, + Address = oppositeTransaction.Address, + WalletName = virtualWallet.Name, + TransactionId = oppositeTransaction.TransactionId, + Network = oppositeTransaction.Network + }; + + await LinkTransactionsAsync( + dbContext, + session, + tx, + oppositeDto, + $"Virtuelles Wallet: {virtualWallet.Name}", + ct); + + return null; + } + + private static async Task ResolveVirtualWalletAsync( + CryptoTrackerDbContext dbContext, + UserResponseDTO response, + CancellationToken ct) + { + if (response.VirtualWalletId.HasValue) + { + return await dbContext.Wallets + .FirstOrDefaultAsync(w => w.Id == response.VirtualWalletId.Value && w.IsVirtual, ct); + } + + var name = response.VirtualWalletName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + return null; + } + + var existing = await dbContext.Wallets + .FirstOrDefaultAsync(w => w.Name == name, ct); + + if (existing != null) + { + return existing.IsVirtual ? existing : null; + } + + var wallet = new Wallet + { + Name = name, + IsVirtual = true + }; + + dbContext.Wallets.Add(wallet); + await dbContext.SaveChangesAsync(ct); + return wallet; + } + private async Task GetTransactionDTOAsync( CryptoTrackerDbContext dbContext, int id, diff --git a/src/CryptoTracker/Controllers/WalletController.cs b/src/CryptoTracker/Controllers/WalletController.cs index 478a25d..0ee0223 100644 --- a/src/CryptoTracker/Controllers/WalletController.cs +++ b/src/CryptoTracker/Controllers/WalletController.cs @@ -36,7 +36,15 @@ public async Task GetWalletsWithSymbols() [HttpGet("GetWalletInfos")] public async Task GetWalletInfos() - => Ok(await _walletService.GetWallets()); + => Ok((await _walletService.GetWallets()) + .Select(w => new WalletInfoDTO(w.Id, w.Name, w.IsVirtual)) + .ToList()); + + [HttpGet("GetVirtualWalletInfos")] + public async Task GetVirtualWalletInfos() + => Ok((await _walletService.GetVirtualWallets()) + .Select(w => new WalletInfoDTO(w.Id, w.Name, w.IsVirtual)) + .ToList()); [HttpPost("SaveWallet")] public async Task SaveWallet([FromBody] Wallet wallet) @@ -59,16 +67,28 @@ async Task> IWalletApi.GetWalletsWithSymbolsAsync() } async Task> IWalletApi.GetWalletInfosAsync() - => (await _walletService.GetWallets()).Select(w => new WalletInfoDTO(w.Id, w.Name)).ToList(); + => (await _walletService.GetWallets()) + .Select(w => new WalletInfoDTO(w.Id, w.Name, w.IsVirtual)) + .ToList(); + + async Task> IWalletApi.GetVirtualWalletInfosAsync() + => (await _walletService.GetVirtualWallets()) + .Select(w => new WalletInfoDTO(w.Id, w.Name, w.IsVirtual)) + .ToList(); async Task IWalletApi.SaveWalletAsync(WalletInfoDTO wallet) { - var entity = await _walletService.SaveWallet(new Wallet { Id = wallet.Id, Name = wallet.Name }); - return new WalletInfoDTO(entity.Id, entity.Name); + var entity = await _walletService.SaveWallet(new Wallet + { + Id = wallet.Id, + Name = wallet.Name, + IsVirtual = wallet.IsVirtual + }); + return new WalletInfoDTO(entity.Id, entity.Name, entity.IsVirtual); } Task IWalletApi.DeleteWalletAsync(int id) { return _walletService.DeleteWallet(id); } -} \ No newline at end of file +} diff --git a/src/CryptoTracker/Entities/Wallet.cs b/src/CryptoTracker/Entities/Wallet.cs index f746a30..8d17bf6 100644 --- a/src/CryptoTracker/Entities/Wallet.cs +++ b/src/CryptoTracker/Entities/Wallet.cs @@ -4,4 +4,5 @@ public class Wallet { public int Id { get; set; } public string Name { get; set; } = string.Empty; + public bool IsVirtual { get; set; } } diff --git a/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.Designer.cs b/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.Designer.cs deleted file mode 100644 index 7f509b6..0000000 --- a/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.Designer.cs +++ /dev/null @@ -1,1107 +0,0 @@ -// -using System; -using CryptoTracker; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace CryptoTracker.Migrations -{ - [DbContext(typeof(CryptoTrackerDbContext))] - [Migration("20260130220034_AddAssetLotTracking")] - partial class AddAssetLotTracking - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.2") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AcquisitionDate") - .HasColumnType("datetimeoffset"); - - b.Property("AcquisitionPriceEur") - .HasColumnType("decimal(27, 12)"); - - b.Property("AcquisitionType") - .HasColumnType("int"); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("CurrentWalletId") - .HasColumnType("int"); - - b.Property("Note") - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalQuantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("ParentLotId") - .HasColumnType("int"); - - b.Property("RemainingQuantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("SourceTradeId") - .HasColumnType("int"); - - b.Property("SourceTransactionId") - .HasColumnType("int"); - - b.Property("Symbol") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.Property("TotalAcquisitionCostEur") - .HasColumnType("decimal(27, 12)"); - - b.HasKey("Id"); - - b.HasIndex("AcquisitionDate"); - - b.HasIndex("ParentLotId"); - - b.HasIndex("SourceTradeId"); - - b.HasIndex("SourceTransactionId"); - - b.HasIndex("CurrentWalletId", "Symbol"); - - b.ToTable("AssetLots"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Comment") - .HasColumnType("nvarchar(max)"); - - b.Property("DateTime") - .HasColumnType("datetimeoffset"); - - b.Property("Fee") - .HasColumnType("decimal(27, 12)"); - - b.Property("ForeignFee") - .HasColumnType("decimal(27, 12)"); - - b.Property("ForeignFeeSymbol") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("LotAssignmentConfirmed") - .HasColumnType("bit"); - - b.Property("OppositeSymbol") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OppositeTradeId") - .HasColumnType("int"); - - b.Property("Price") - .HasColumnType("decimal(27, 12)"); - - b.Property("Quantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("Referenz") - .HasColumnType("nvarchar(max)"); - - b.Property("ResultingLotId") - .HasColumnType("int"); - - b.Property("Symbol") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TradeType") - .HasColumnType("int"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("OppositeTradeId") - .IsUnique() - .HasFilter("[OppositeTradeId] IS NOT NULL"); - - b.HasIndex("ResultingLotId"); - - b.HasIndex("WalletId"); - - b.ToTable("CryptoTrades"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .HasColumnType("nvarchar(max)"); - - b.Property("DateTime") - .HasColumnType("datetimeoffset"); - - b.Property("Fee") - .HasColumnType("decimal(27, 12)"); - - b.Property("LotAssignmentConfirmed") - .HasColumnType("bit"); - - b.Property("Network") - .HasColumnType("nvarchar(max)"); - - b.Property("OppositeTransactionId") - .HasColumnType("int"); - - b.Property("OppositeWalletId") - .HasColumnType("int"); - - b.Property("Quantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("ResultingLotId") - .HasColumnType("int"); - - b.Property("Symbol") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionId") - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .HasColumnType("int"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("OppositeTransactionId") - .IsUnique() - .HasFilter("[OppositeTransactionId] IS NOT NULL"); - - b.HasIndex("OppositeWalletId"); - - b.HasIndex("ResultingLotId"); - - b.HasIndex("WalletId"); - - b.ToTable("CryptoTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TXID") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BinanceDeposits"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Executed") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Fee") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pair") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .HasColumnType("decimal(18,2)"); - - b.Property("Side") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BinanceTrades"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TXID") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BinanceWithdrawals"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Adresse") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("CryptoNachGebuehr") - .HasColumnType("decimal(18,2)"); - - b.Property("CryptoVorGebuehr") - .HasColumnType("decimal(18,2)"); - - b.Property("Datum") - .HasColumnType("datetimeoffset"); - - b.Property("EinheitKurs") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("EinheitMengeNachGebuehr") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("EinheitMengeVorGebuehr") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Kommentar") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Kontostand") - .HasColumnType("decimal(18,2)"); - - b.Property("Kurs") - .HasColumnType("decimal(18,2)"); - - b.Property("MengeNachGebuehr") - .HasColumnType("decimal(18,2)"); - - b.Property("MengeVorGebuehr") - .HasColumnType("decimal(18,2)"); - - b.Property("Referenz") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Typ") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Waehrung") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.Property("ZuAbgang") - .HasColumnType("decimal(18,2)"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BitcoinDeTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("AmountAsset") - .HasColumnType("decimal(18,2)"); - - b.Property("AmountFiat") - .HasColumnType("decimal(18,2)"); - - b.Property("Asset") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("AssetClass") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("AssetMarketPrice") - .HasColumnType("decimal(18,2)"); - - b.Property("AssetMarketPriceCurrency") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Fee") - .HasColumnType("decimal(18,2)"); - - b.Property("FeeAsset") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Fiat") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("InOut") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ProductID") - .HasColumnType("int"); - - b.Property("Spread") - .HasColumnType("decimal(18,2)"); - - b.Property("SpreadCurrency") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TaxFiat") - .HasColumnType("decimal(18,2)"); - - b.Property("Timestamp") - .HasColumnType("datetimeoffset"); - - b.Property("TransactionId") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BitpandaTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Datum") - .HasColumnType("datetimeoffset"); - - b.Property("Kommentar") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("Typ") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("LedgerTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Executed") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Fee") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pair") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Side") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Tradingplatform") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("MetamaskTrades"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Datum") - .HasColumnType("datetimeoffset"); - - b.Property("Kommentar") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("Typ") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("MetamaskTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TXID") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("OkxDeposits"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Executed") - .HasColumnType("decimal(18,2)"); - - b.Property("Pair") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Side") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("OkxTrades"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("DateTime") - .HasColumnType("datetimeoffset"); - - b.Property("IsTaxFree") - .HasColumnType("bit"); - - b.Property("LotId") - .HasColumnType("int"); - - b.Property("MovementType") - .HasColumnType("int"); - - b.Property("Note") - .HasColumnType("nvarchar(max)"); - - b.Property("Quantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("RealizedGainEur") - .HasColumnType("decimal(27, 12)"); - - b.Property("ResultingLotId") - .HasColumnType("int"); - - b.Property("SalePriceEur") - .HasColumnType("decimal(27, 12)"); - - b.Property("TaxFreeReason") - .HasColumnType("int"); - - b.Property("TradeId") - .HasColumnType("int"); - - b.Property("TransactionId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("DateTime"); - - b.HasIndex("LotId"); - - b.HasIndex("ResultingLotId"); - - b.HasIndex("TradeId"); - - b.HasIndex("TransactionId"); - - b.ToTable("LotMovements"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.ManualCoinPrice", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("Date") - .HasColumnType("date"); - - b.Property("PriceEur") - .HasColumnType("decimal(27, 12)"); - - b.Property("Symbol") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("Symbol", "Date") - .IsUnique(); - - b.ToTable("ManualCoinPrices"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Wallet", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("Wallets"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "CurrentWallet") - .WithMany() - .HasForeignKey("CurrentWalletId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("CryptoTracker.Entities.AssetLot", "ParentLot") - .WithMany("ChildLots") - .HasForeignKey("ParentLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.CryptoTrade", "SourceTrade") - .WithMany() - .HasForeignKey("SourceTradeId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.CryptoTransaction", "SourceTransaction") - .WithMany() - .HasForeignKey("SourceTransactionId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("CurrentWallet"); - - b.Navigation("ParentLot"); - - b.Navigation("SourceTrade"); - - b.Navigation("SourceTransaction"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => - { - b.HasOne("CryptoTracker.Entities.CryptoTrade", "OppositeTrade") - .WithOne() - .HasForeignKey("CryptoTracker.Entities.CryptoTrade", "OppositeTradeId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") - .WithMany() - .HasForeignKey("ResultingLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("OppositeTrade"); - - b.Navigation("ResultingLot"); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => - { - b.HasOne("CryptoTracker.Entities.CryptoTransaction", "OppositeTransaction") - .WithOne() - .HasForeignKey("CryptoTracker.Entities.CryptoTransaction", "OppositeTransactionId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.Wallet", "OppositeWallet") - .WithMany() - .HasForeignKey("OppositeWalletId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") - .WithMany() - .HasForeignKey("ResultingLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("OppositeTransaction"); - - b.Navigation("OppositeWallet"); - - b.Navigation("ResultingLot"); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => - { - b.HasOne("CryptoTracker.Entities.AssetLot", "Lot") - .WithMany("Movements") - .HasForeignKey("LotId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") - .WithMany() - .HasForeignKey("ResultingLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.CryptoTrade", "Trade") - .WithMany("LotMovements") - .HasForeignKey("TradeId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.CryptoTransaction", "Transaction") - .WithMany("LotMovements") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Lot"); - - b.Navigation("ResultingLot"); - - b.Navigation("Trade"); - - b.Navigation("Transaction"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => - { - b.Navigation("ChildLots"); - - b.Navigation("Movements"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => - { - b.Navigation("LotMovements"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => - { - b.Navigation("LotMovements"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.Designer.cs b/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.Designer.cs deleted file mode 100644 index d3cca13..0000000 --- a/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.Designer.cs +++ /dev/null @@ -1,1139 +0,0 @@ -// -using System; -using CryptoTracker; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace CryptoTracker.Migrations -{ - [DbContext(typeof(CryptoTrackerDbContext))] - [Migration("20260131122534_AddLotFlowTracking")] - partial class AddLotFlowTracking - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.2") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AcquisitionDate") - .HasColumnType("datetimeoffset"); - - b.Property("AcquisitionPriceEur") - .HasColumnType("decimal(27, 12)"); - - b.Property("AcquisitionType") - .HasColumnType("int"); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("CurrentWalletId") - .HasColumnType("int"); - - b.Property("FlowIncompleteReason") - .HasColumnType("nvarchar(max)"); - - b.Property("IsFlowComplete") - .HasColumnType("bit"); - - b.Property("Note") - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalQuantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("ParentLotId") - .HasColumnType("int"); - - b.Property("RemainingQuantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("SourceTradeId") - .HasColumnType("int"); - - b.Property("SourceTransactionId") - .HasColumnType("int"); - - b.Property("Symbol") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.Property("TotalAcquisitionCostEur") - .HasColumnType("decimal(27, 12)"); - - b.Property("TransformedToLotId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AcquisitionDate"); - - b.HasIndex("ParentLotId"); - - b.HasIndex("SourceTradeId"); - - b.HasIndex("SourceTransactionId"); - - b.HasIndex("TransformedToLotId"); - - b.HasIndex("CurrentWalletId", "Symbol"); - - b.ToTable("AssetLots"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Comment") - .HasColumnType("nvarchar(max)"); - - b.Property("DateTime") - .HasColumnType("datetimeoffset"); - - b.Property("Fee") - .HasColumnType("decimal(27, 12)"); - - b.Property("ForeignFee") - .HasColumnType("decimal(27, 12)"); - - b.Property("ForeignFeeSymbol") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("LotAssignmentConfirmed") - .HasColumnType("bit"); - - b.Property("OppositeSymbol") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OppositeTradeId") - .HasColumnType("int"); - - b.Property("Price") - .HasColumnType("decimal(27, 12)"); - - b.Property("Quantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("Referenz") - .HasColumnType("nvarchar(max)"); - - b.Property("ResultingLotId") - .HasColumnType("int"); - - b.Property("SourceLotId") - .HasColumnType("int"); - - b.Property("Symbol") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TradeType") - .HasColumnType("int"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("OppositeTradeId") - .IsUnique() - .HasFilter("[OppositeTradeId] IS NOT NULL"); - - b.HasIndex("ResultingLotId"); - - b.HasIndex("SourceLotId"); - - b.HasIndex("WalletId"); - - b.ToTable("CryptoTrades"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .HasColumnType("nvarchar(max)"); - - b.Property("DateTime") - .HasColumnType("datetimeoffset"); - - b.Property("Fee") - .HasColumnType("decimal(27, 12)"); - - b.Property("LotAssignmentConfirmed") - .HasColumnType("bit"); - - b.Property("Network") - .HasColumnType("nvarchar(max)"); - - b.Property("OppositeTransactionId") - .HasColumnType("int"); - - b.Property("OppositeWalletId") - .HasColumnType("int"); - - b.Property("Quantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("ResultingLotId") - .HasColumnType("int"); - - b.Property("Symbol") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionId") - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .HasColumnType("int"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("OppositeTransactionId") - .IsUnique() - .HasFilter("[OppositeTransactionId] IS NOT NULL"); - - b.HasIndex("OppositeWalletId"); - - b.HasIndex("ResultingLotId"); - - b.HasIndex("WalletId"); - - b.ToTable("CryptoTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TXID") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BinanceDeposits"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Executed") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Fee") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pair") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .HasColumnType("decimal(18,2)"); - - b.Property("Side") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BinanceTrades"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TXID") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BinanceWithdrawals"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Adresse") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("CryptoNachGebuehr") - .HasColumnType("decimal(18,2)"); - - b.Property("CryptoVorGebuehr") - .HasColumnType("decimal(18,2)"); - - b.Property("Datum") - .HasColumnType("datetimeoffset"); - - b.Property("EinheitKurs") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("EinheitMengeNachGebuehr") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("EinheitMengeVorGebuehr") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Kommentar") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Kontostand") - .HasColumnType("decimal(18,2)"); - - b.Property("Kurs") - .HasColumnType("decimal(18,2)"); - - b.Property("MengeNachGebuehr") - .HasColumnType("decimal(18,2)"); - - b.Property("MengeVorGebuehr") - .HasColumnType("decimal(18,2)"); - - b.Property("Referenz") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Typ") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Waehrung") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.Property("ZuAbgang") - .HasColumnType("decimal(18,2)"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BitcoinDeTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("AmountAsset") - .HasColumnType("decimal(18,2)"); - - b.Property("AmountFiat") - .HasColumnType("decimal(18,2)"); - - b.Property("Asset") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("AssetClass") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("AssetMarketPrice") - .HasColumnType("decimal(18,2)"); - - b.Property("AssetMarketPriceCurrency") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Fee") - .HasColumnType("decimal(18,2)"); - - b.Property("FeeAsset") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Fiat") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("InOut") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ProductID") - .HasColumnType("int"); - - b.Property("Spread") - .HasColumnType("decimal(18,2)"); - - b.Property("SpreadCurrency") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TaxFiat") - .HasColumnType("decimal(18,2)"); - - b.Property("Timestamp") - .HasColumnType("datetimeoffset"); - - b.Property("TransactionId") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("BitpandaTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Datum") - .HasColumnType("datetimeoffset"); - - b.Property("Kommentar") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("Typ") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("LedgerTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Executed") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Fee") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pair") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Side") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Tradingplatform") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("MetamaskTrades"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Datum") - .HasColumnType("datetimeoffset"); - - b.Property("Kommentar") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("Typ") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("MetamaskTransactions"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Address") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Coin") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Comment") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Network") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TXID") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionFee") - .HasColumnType("decimal(18,2)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("OkxDeposits"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Date") - .HasColumnType("datetimeoffset"); - - b.Property("Executed") - .HasColumnType("decimal(18,2)"); - - b.Property("Pair") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Side") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WalletId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("WalletId"); - - b.ToTable("OkxTrades"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("DateTime") - .HasColumnType("datetimeoffset"); - - b.Property("IsTaxFree") - .HasColumnType("bit"); - - b.Property("LotId") - .HasColumnType("int"); - - b.Property("MovementType") - .HasColumnType("int"); - - b.Property("Note") - .HasColumnType("nvarchar(max)"); - - b.Property("Quantity") - .HasColumnType("decimal(27, 12)"); - - b.Property("RealizedGainEur") - .HasColumnType("decimal(27, 12)"); - - b.Property("ResultingLotId") - .HasColumnType("int"); - - b.Property("SalePriceEur") - .HasColumnType("decimal(27, 12)"); - - b.Property("TaxFreeReason") - .HasColumnType("int"); - - b.Property("TradeId") - .HasColumnType("int"); - - b.Property("TransactionId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("DateTime"); - - b.HasIndex("LotId"); - - b.HasIndex("ResultingLotId"); - - b.HasIndex("TradeId"); - - b.HasIndex("TransactionId"); - - b.ToTable("LotMovements"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.ManualCoinPrice", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("Date") - .HasColumnType("date"); - - b.Property("PriceEur") - .HasColumnType("decimal(27, 12)"); - - b.Property("Symbol") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("Symbol", "Date") - .IsUnique(); - - b.ToTable("ManualCoinPrices"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Wallet", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("Wallets"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "CurrentWallet") - .WithMany() - .HasForeignKey("CurrentWalletId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("CryptoTracker.Entities.AssetLot", "ParentLot") - .WithMany("ChildLots") - .HasForeignKey("ParentLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.CryptoTrade", "SourceTrade") - .WithMany() - .HasForeignKey("SourceTradeId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.CryptoTransaction", "SourceTransaction") - .WithMany() - .HasForeignKey("SourceTransactionId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.AssetLot", "TransformedToLot") - .WithMany("TransformedFromLots") - .HasForeignKey("TransformedToLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("CurrentWallet"); - - b.Navigation("ParentLot"); - - b.Navigation("SourceTrade"); - - b.Navigation("SourceTransaction"); - - b.Navigation("TransformedToLot"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => - { - b.HasOne("CryptoTracker.Entities.CryptoTrade", "OppositeTrade") - .WithOne() - .HasForeignKey("CryptoTracker.Entities.CryptoTrade", "OppositeTradeId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") - .WithMany() - .HasForeignKey("ResultingLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.AssetLot", "SourceLot") - .WithMany() - .HasForeignKey("SourceLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("OppositeTrade"); - - b.Navigation("ResultingLot"); - - b.Navigation("SourceLot"); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => - { - b.HasOne("CryptoTracker.Entities.CryptoTransaction", "OppositeTransaction") - .WithOne() - .HasForeignKey("CryptoTracker.Entities.CryptoTransaction", "OppositeTransactionId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.Wallet", "OppositeWallet") - .WithMany() - .HasForeignKey("OppositeWalletId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") - .WithMany() - .HasForeignKey("ResultingLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("OppositeTransaction"); - - b.Navigation("OppositeWallet"); - - b.Navigation("ResultingLot"); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceDepositEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceTradeEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BinanceWithdrawalEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BitcoinDeTransactionEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.BitpandaTransactionEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.LedgerTransactionEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTradeEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.MetamaskTransactionEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.OkxDepositEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.Import.OkxTradeEntity", b => - { - b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") - .WithMany() - .HasForeignKey("WalletId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Wallet"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.LotMovement", b => - { - b.HasOne("CryptoTracker.Entities.AssetLot", "Lot") - .WithMany("Movements") - .HasForeignKey("LotId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("CryptoTracker.Entities.AssetLot", "ResultingLot") - .WithMany() - .HasForeignKey("ResultingLotId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.CryptoTrade", "Trade") - .WithMany("LotMovements") - .HasForeignKey("TradeId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("CryptoTracker.Entities.CryptoTransaction", "Transaction") - .WithMany("LotMovements") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Lot"); - - b.Navigation("ResultingLot"); - - b.Navigation("Trade"); - - b.Navigation("Transaction"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.AssetLot", b => - { - b.Navigation("ChildLots"); - - b.Navigation("Movements"); - - b.Navigation("TransformedFromLots"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTrade", b => - { - b.Navigation("LotMovements"); - }); - - modelBuilder.Entity("CryptoTracker.Entities.CryptoTransaction", b => - { - b.Navigation("LotMovements"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.cs b/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.cs deleted file mode 100644 index b38d634..0000000 --- a/src/CryptoTracker/Migrations/20260131122534_AddLotFlowTracking.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace CryptoTracker.Migrations -{ - /// - public partial class AddLotFlowTracking : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SourceLotId", - table: "CryptoTrades", - type: "int", - nullable: true); - - migrationBuilder.AddColumn( - name: "FlowIncompleteReason", - table: "AssetLots", - type: "nvarchar(max)", - nullable: true); - - migrationBuilder.AddColumn( - name: "IsFlowComplete", - table: "AssetLots", - type: "bit", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "TransformedToLotId", - table: "AssetLots", - type: "int", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_CryptoTrades_SourceLotId", - table: "CryptoTrades", - column: "SourceLotId"); - - migrationBuilder.CreateIndex( - name: "IX_AssetLots_TransformedToLotId", - table: "AssetLots", - column: "TransformedToLotId"); - - migrationBuilder.AddForeignKey( - name: "FK_AssetLots_AssetLots_TransformedToLotId", - table: "AssetLots", - column: "TransformedToLotId", - principalTable: "AssetLots", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - - migrationBuilder.AddForeignKey( - name: "FK_CryptoTrades_AssetLots_SourceLotId", - table: "CryptoTrades", - column: "SourceLotId", - principalTable: "AssetLots", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_AssetLots_AssetLots_TransformedToLotId", - table: "AssetLots"); - - migrationBuilder.DropForeignKey( - name: "FK_CryptoTrades_AssetLots_SourceLotId", - table: "CryptoTrades"); - - migrationBuilder.DropIndex( - name: "IX_CryptoTrades_SourceLotId", - table: "CryptoTrades"); - - migrationBuilder.DropIndex( - name: "IX_AssetLots_TransformedToLotId", - table: "AssetLots"); - - migrationBuilder.DropColumn( - name: "SourceLotId", - table: "CryptoTrades"); - - migrationBuilder.DropColumn( - name: "FlowIncompleteReason", - table: "AssetLots"); - - migrationBuilder.DropColumn( - name: "IsFlowComplete", - table: "AssetLots"); - - migrationBuilder.DropColumn( - name: "TransformedToLotId", - table: "AssetLots"); - } - } -} diff --git a/src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.cs b/src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.cs deleted file mode 100644 index 654f475..0000000 --- a/src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace CryptoTracker.Migrations -{ - /// - public partial class AddAILinkingEntities : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "IsIntentionallyUnlinked", - table: "CryptoTransactions", - type: "bit", - nullable: false, - defaultValue: false); - - migrationBuilder.CreateTable( - name: "AgentMemories", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - AgentKey = table.Column(type: "nvarchar(450)", nullable: false), - MemoryType = table.Column(type: "int", nullable: false), - Key = table.Column(type: "nvarchar(450)", nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: false), - Description = table.Column(type: "nvarchar(max)", nullable: true), - CreatedAt = table.Column(type: "datetimeoffset", nullable: false), - UsageCount = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AgentMemories", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "TransactionLinkMetadata", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - TransactionId = table.Column(type: "int", nullable: false), - LinkType = table.Column(type: "int", nullable: false), - Confidence = table.Column(type: "decimal(5,4)", nullable: false), - Reason = table.Column(type: "nvarchar(max)", nullable: true), - IsConfirmed = table.Column(type: "bit", nullable: false), - LinkedAt = table.Column(type: "datetimeoffset", nullable: false), - ConfirmedAt = table.Column(type: "datetimeoffset", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_TransactionLinkMetadata", x => x.Id); - table.ForeignKey( - name: "FK_TransactionLinkMetadata_CryptoTransactions_TransactionId", - column: x => x.TransactionId, - principalTable: "CryptoTransactions", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_AgentMemories_AgentKey", - table: "AgentMemories", - column: "AgentKey"); - - migrationBuilder.CreateIndex( - name: "IX_AgentMemories_AgentKey_MemoryType_Key", - table: "AgentMemories", - columns: new[] { "AgentKey", "MemoryType", "Key" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_TransactionLinkMetadata_IsConfirmed", - table: "TransactionLinkMetadata", - column: "IsConfirmed"); - - migrationBuilder.CreateIndex( - name: "IX_TransactionLinkMetadata_LinkType", - table: "TransactionLinkMetadata", - column: "LinkType"); - - migrationBuilder.CreateIndex( - name: "IX_TransactionLinkMetadata_TransactionId", - table: "TransactionLinkMetadata", - column: "TransactionId", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AgentMemories"); - - migrationBuilder.DropTable( - name: "TransactionLinkMetadata"); - - migrationBuilder.DropColumn( - name: "IsIntentionallyUnlinked", - table: "CryptoTransactions"); - } - } -} diff --git a/src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.Designer.cs b/src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.Designer.cs similarity index 99% rename from src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.Designer.cs rename to src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.Designer.cs index 1e1fa24..b3e8351 100644 --- a/src/CryptoTracker/Migrations/20260131133250_AddAILinkingEntities.Designer.cs +++ b/src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.Designer.cs @@ -12,8 +12,8 @@ namespace CryptoTracker.Migrations { [DbContext(typeof(CryptoTrackerDbContext))] - [Migration("20260131133250_AddAILinkingEntities")] - partial class AddAILinkingEntities + [Migration("20260201135201_TransactionLinkingAndLots")] + partial class TransactionLinkingAndLots { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -165,6 +165,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("IsHidden") + .HasColumnType("bit"); + b.Property("LotAssignmentConfirmed") .HasColumnType("bit"); @@ -235,6 +238,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Fee") .HasColumnType("decimal(27, 12)"); + b.Property("IsHidden") + .HasColumnType("bit"); + b.Property("IsIntentionallyUnlinked") .HasColumnType("bit"); @@ -944,6 +950,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("IsVirtual") + .HasColumnType("bit"); + b.Property("Name") .IsRequired() .HasColumnType("nvarchar(450)"); diff --git a/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.cs b/src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.cs similarity index 63% rename from src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.cs rename to src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.cs index 1ea5b89..0669d82 100644 --- a/src/CryptoTracker/Migrations/20260130220034_AddAssetLotTracking.cs +++ b/src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.cs @@ -6,11 +6,25 @@ namespace CryptoTracker.Migrations { /// - public partial class AddAssetLotTracking : Migration + public partial class TransactionLinkingAndLots : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.AddColumn( + name: "IsVirtual", + table: "Wallets", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsIntentionallyUnlinked", + table: "CryptoTransactions", + type: "bit", + nullable: false, + defaultValue: false); + migrationBuilder.AddColumn( name: "LotAssignmentConfirmed", table: "CryptoTransactions", @@ -37,6 +51,31 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "int", nullable: true); + migrationBuilder.AddColumn( + name: "SourceLotId", + table: "CryptoTrades", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "AgentMemories", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + AgentKey = table.Column(type: "nvarchar(450)", nullable: false), + MemoryType = table.Column(type: "int", nullable: false), + Key = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + UsageCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AgentMemories", x => x.Id); + }); + migrationBuilder.CreateTable( name: "AssetLots", columns: table => new @@ -54,6 +93,9 @@ protected override void Up(MigrationBuilder migrationBuilder) SourceTransactionId = table.Column(type: "int", nullable: true), SourceTradeId = table.Column(type: "int", nullable: true), ParentLotId = table.Column(type: "int", nullable: true), + IsFlowComplete = table.Column(type: "bit", nullable: false), + FlowIncompleteReason = table.Column(type: "nvarchar(max)", nullable: true), + TransformedToLotId = table.Column(type: "int", nullable: true), Note = table.Column(type: "nvarchar(max)", nullable: true), CreatedAt = table.Column(type: "datetimeoffset", nullable: false) }, @@ -66,6 +108,12 @@ protected override void Up(MigrationBuilder migrationBuilder) principalTable: "AssetLots", principalColumn: "Id", onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_AssetLots_AssetLots_TransformedToLotId", + column: x => x.TransformedToLotId, + principalTable: "AssetLots", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); table.ForeignKey( name: "FK_AssetLots_CryptoTrades_SourceTradeId", column: x => x.SourceTradeId, @@ -86,6 +134,31 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Restrict); }); + migrationBuilder.CreateTable( + name: "TransactionLinkMetadata", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TransactionId = table.Column(type: "int", nullable: false), + LinkType = table.Column(type: "int", nullable: false), + Confidence = table.Column(type: "decimal(5,4)", nullable: false), + Reason = table.Column(type: "nvarchar(max)", nullable: true), + IsConfirmed = table.Column(type: "bit", nullable: false), + LinkedAt = table.Column(type: "datetimeoffset", nullable: false), + ConfirmedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TransactionLinkMetadata", x => x.Id); + table.ForeignKey( + name: "FK_TransactionLinkMetadata_CryptoTransactions_TransactionId", + column: x => x.TransactionId, + principalTable: "CryptoTransactions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "LotMovements", columns: table => new @@ -145,6 +218,22 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "CryptoTrades", column: "ResultingLotId"); + migrationBuilder.CreateIndex( + name: "IX_CryptoTrades_SourceLotId", + table: "CryptoTrades", + column: "SourceLotId"); + + migrationBuilder.CreateIndex( + name: "IX_AgentMemories_AgentKey", + table: "AgentMemories", + column: "AgentKey"); + + migrationBuilder.CreateIndex( + name: "IX_AgentMemories_AgentKey_MemoryType_Key", + table: "AgentMemories", + columns: new[] { "AgentKey", "MemoryType", "Key" }, + unique: true); + migrationBuilder.CreateIndex( name: "IX_AssetLots_AcquisitionDate", table: "AssetLots", @@ -170,6 +259,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "AssetLots", column: "SourceTransactionId"); + migrationBuilder.CreateIndex( + name: "IX_AssetLots_TransformedToLotId", + table: "AssetLots", + column: "TransformedToLotId"); + migrationBuilder.CreateIndex( name: "IX_LotMovements_DateTime", table: "LotMovements", @@ -195,6 +289,22 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "LotMovements", column: "TransactionId"); + migrationBuilder.CreateIndex( + name: "IX_TransactionLinkMetadata_IsConfirmed", + table: "TransactionLinkMetadata", + column: "IsConfirmed"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLinkMetadata_LinkType", + table: "TransactionLinkMetadata", + column: "LinkType"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLinkMetadata_TransactionId", + table: "TransactionLinkMetadata", + column: "TransactionId", + unique: true); + migrationBuilder.AddForeignKey( name: "FK_CryptoTrades_AssetLots_ResultingLotId", table: "CryptoTrades", @@ -203,6 +313,14 @@ protected override void Up(MigrationBuilder migrationBuilder) principalColumn: "Id", onDelete: ReferentialAction.Restrict); + migrationBuilder.AddForeignKey( + name: "FK_CryptoTrades_AssetLots_SourceLotId", + table: "CryptoTrades", + column: "SourceLotId", + principalTable: "AssetLots", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + migrationBuilder.AddForeignKey( name: "FK_CryptoTransactions_AssetLots_ResultingLotId", table: "CryptoTransactions", @@ -219,13 +337,23 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "FK_CryptoTrades_AssetLots_ResultingLotId", table: "CryptoTrades"); + migrationBuilder.DropForeignKey( + name: "FK_CryptoTrades_AssetLots_SourceLotId", + table: "CryptoTrades"); + migrationBuilder.DropForeignKey( name: "FK_CryptoTransactions_AssetLots_ResultingLotId", table: "CryptoTransactions"); + migrationBuilder.DropTable( + name: "AgentMemories"); + migrationBuilder.DropTable( name: "LotMovements"); + migrationBuilder.DropTable( + name: "TransactionLinkMetadata"); + migrationBuilder.DropTable( name: "AssetLots"); @@ -237,6 +365,18 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "IX_CryptoTrades_ResultingLotId", table: "CryptoTrades"); + migrationBuilder.DropIndex( + name: "IX_CryptoTrades_SourceLotId", + table: "CryptoTrades"); + + migrationBuilder.DropColumn( + name: "IsVirtual", + table: "Wallets"); + + migrationBuilder.DropColumn( + name: "IsIntentionallyUnlinked", + table: "CryptoTransactions"); + migrationBuilder.DropColumn( name: "LotAssignmentConfirmed", table: "CryptoTransactions"); @@ -252,6 +392,10 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropColumn( name: "ResultingLotId", table: "CryptoTrades"); + + migrationBuilder.DropColumn( + name: "SourceLotId", + table: "CryptoTrades"); } } } diff --git a/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs b/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs index 9e7d16c..d156349 100644 --- a/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs +++ b/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs @@ -947,6 +947,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("IsVirtual") + .HasColumnType("bit"); + b.Property("Name") .IsRequired() .HasColumnType("nvarchar(450)"); diff --git a/src/CryptoTracker/Services/WalletService.cs b/src/CryptoTracker/Services/WalletService.cs index 6c74aca..76220de 100644 --- a/src/CryptoTracker/Services/WalletService.cs +++ b/src/CryptoTracker/Services/WalletService.cs @@ -35,6 +35,12 @@ public WalletService(CryptoTrackerDbContext dbContext) public async Task> GetWallets() => await _dbContext.Wallets.OrderBy(w => w.Name).ToListAsync(); + public async Task> GetVirtualWallets() + => await _dbContext.Wallets + .Where(w => w.IsVirtual) + .OrderBy(w => w.Name) + .ToListAsync(); + public async Task SaveWallet(Wallet wallet) { if (wallet.Id == 0) From e851e51e4be128b35cf98e21742d8f9da62734d6 Mon Sep 17 00:00:00 2001 From: Thomas Humer Date: Sun, 1 Feb 2026 23:24:07 +0100 Subject: [PATCH 7/7] feat(migrations): add transaction linking and asset lots management - Introduced new columns in Wallets, CryptoTransactions, and CryptoTrades for virtual wallets and lot assignment tracking. - Created new tables: AgentMemories, AssetLots, TransactionLinkMetadata, and LotMovements to manage asset lots and transaction links. - Added foreign key relationships and indexes to enhance data integrity and query performance. feat(services): implement FiatSymbols utility for currency validation - Added FiatSymbols class to provide a list of fiat currency symbols and a method to validate if a given symbol is a fiat currency. feat(js): enhance overlay management in app.js - Implemented functions to manage overlay states and track manual overlay counts. - Added a MutationObserver to dynamically update overlay states based on DOM changes. --- plans/agentic-linking-redesign.md | 123 ++ .../Shared/LinkingWizard.razor | 14 +- .../Shared/LinkingWizard.razor.cs | 43 +- src/CryptoTracker.Client/Shared/LotDTOs.cs | 9 +- .../Shared/LotLinkingWizard.razor | 128 ++- .../Shared/LotLinkingWizard.razor.cs | 218 +++- .../Agent/Common/LinkingAgentContext.cs | 68 ++ .../Agent/Common/LotLinkingAgentContext.cs | 68 ++ .../Definitions/LotLinkingAgentDefinition.cs | 91 ++ .../TransactionLinkingAgentDefinition.cs | 93 +- .../Services/InteractiveLinkingService.cs | 855 +++----------- .../Services/InteractiveLotLinkingService.cs | 1006 +++-------------- .../Services/TransactionLinkingService.cs | 208 +--- .../Agent/Tools/AskLinkingQuestionTool.cs | 113 ++ .../Agent/Tools/AskLotLinkingQuestionTool.cs | 228 ++++ .../Agent/Tools/CreateRootLotTool.cs | 165 +++ .../Agent/Tools/GetLotMemoryTool.cs | 68 ++ .../Agent/Tools/GetLotOptionsTool.cs | 107 ++ .../Tools/GetPendingLotAssignmentsTool.cs | 99 ++ .../Agent/Tools/GetTradeDetailsTool.cs | 67 ++ .../Agent/Tools/LinkTransactionsTool.cs | 26 +- .../Agent/Tools/LinkVirtualWalletTool.cs | 161 +++ .../Agent/Tools/LogLinkingEventTool.cs | 51 + .../Agent/Tools/LogLotLinkingEventTool.cs | 51 + .../Tools/MarkAsIntentionallyUnlinkedTool.cs | 42 +- .../Agent/Tools/SaveAgentMemoryTool.cs | 16 +- .../Agent/Tools/SaveLotMemoryTool.cs | 92 ++ src/CryptoTracker/Agent/Tools/SellLotsTool.cs | 190 ++++ .../Agent/Tools/SkipLotAssignmentTool.cs | 52 + .../Agent/Tools/SkipTransactionTool.cs | 54 + .../Agent/Tools/TransferLotsTool.cs | 198 ++++ .../Agent/Tools/TransformSwapLotsTool.cs | 211 ++++ src/CryptoTracker/Components/App.razor | 1 + .../Controllers/LotsController.cs | 18 +- ...654_TransactionLinkingAndLots.Designer.cs} | 2 +- ...260201160654_TransactionLinkingAndLots.cs} | 0 src/CryptoTracker/Services/FiatSymbols.cs | 23 + src/CryptoTracker/Services/LotService.cs | 15 +- src/CryptoTracker/Startup.cs | 20 + src/CryptoTracker/wwwroot/app.css | 34 +- src/CryptoTracker/wwwroot/app.js | 58 + 41 files changed, 3200 insertions(+), 1886 deletions(-) create mode 100644 plans/agentic-linking-redesign.md create mode 100644 src/CryptoTracker/Agent/Common/LinkingAgentContext.cs create mode 100644 src/CryptoTracker/Agent/Common/LotLinkingAgentContext.cs create mode 100644 src/CryptoTracker/Agent/Definitions/LotLinkingAgentDefinition.cs create mode 100644 src/CryptoTracker/Agent/Tools/AskLinkingQuestionTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/AskLotLinkingQuestionTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/CreateRootLotTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/GetLotMemoryTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/GetLotOptionsTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/GetPendingLotAssignmentsTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/GetTradeDetailsTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/LinkVirtualWalletTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/LogLinkingEventTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/LogLotLinkingEventTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/SaveLotMemoryTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/SellLotsTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/SkipLotAssignmentTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/SkipTransactionTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/TransferLotsTool.cs create mode 100644 src/CryptoTracker/Agent/Tools/TransformSwapLotsTool.cs rename src/CryptoTracker/Migrations/{20260201135201_TransactionLinkingAndLots.Designer.cs => 20260201160654_TransactionLinkingAndLots.Designer.cs} (99%) rename src/CryptoTracker/Migrations/{20260201135201_TransactionLinkingAndLots.cs => 20260201160654_TransactionLinkingAndLots.cs} (100%) create mode 100644 src/CryptoTracker/Services/FiatSymbols.cs create mode 100644 src/CryptoTracker/wwwroot/app.js diff --git a/plans/agentic-linking-redesign.md b/plans/agentic-linking-redesign.md new file mode 100644 index 0000000..857bf6e --- /dev/null +++ b/plans/agentic-linking-redesign.md @@ -0,0 +1,123 @@ +# Agentic Linking Redesign (Transaction + Lot) + +## Ziel & Umfang +Dieser Plan beschreibt den Umbau des Transaction‑Linking und Lot‑Linking in einen agentischen Workflow mit wiederholten Agent‑Runs, Session‑Kontext und menschlicher Bestätigung nur bei Unsicherheit. Der LLM ersetzt die bisherige heuristische Auto‑Link‑Logik. Lot‑Linking wird vollständig neu aufgebaut und stellt eine durchgängige Lot‑Verkettung bis zu Root‑Lots sicher. + +## Agent-Framework Quellcode +- Lokaler Pfad: `D:\Src\agent-framework` + +## Leitprinzipien +- **Agentisch, iterativ:** Der Agent startet selbstständig, stellt bei Bedarf Fragen, arbeitet nach Antworten weiter. +- **Kontextbewusst:** Entscheidungen aus der Session fließen in den Verlauf ein (Session‑History), Persistenz nur bei expliziter Zustimmung. +- **Minimal‑UI‑Interaktion:** Der Benutzer wird nur bei Unsicherheit eingebunden. +- **Keine Hardcodings:** Keine fixen Mapping‑Listen (z. B. „Staking Rewards“). Muster werden gelernt. +- **Lot‑Tracing vollständig:** Jede Transaktion muss zu einem Ursprungslot zurückverfolgbar sein. + +--- + +# Teil A: Technischer Plan für den neuen Agenten (Transaction Linking) + +## 1) Architektur‑Übersicht +- **AgentSession** (neu): Zustand für Konversation, offene Fragen, Fortschritt, Abbruch. +- **AgentRunner** (neu): Orchestriert wiederholte `agent.RunAsync`‑Aufrufe, übernimmt Frage‑/Antwort‑Loop. +- **Session‑Store** (bestehendes Dictionary erweitern): Speichert aktive Session + History. +- **Tool‑Events → SignalR**: Tool‑Calls erzeugen Events für UI (linked, marked_external, question, progress, rule_learned, error). + +## 2) Session‑Flow (Transaction) +1. Session starten → Agent initialisiert Kontext (Regeln + unverknüpfte TX). +2. Agent arbeitet autonom, ruft Tools auf (link, mark_unlinked, etc.). +3. Bei Unsicherheit erzeugt der Agent eine Frage mit Optionen **+ „Andere Option (Freitext)“**. +4. Benutzerantwort wird in Session‑History aufgenommen → Agent läuft erneut weiter. +5. `save_memory` **nur** wenn `ShouldRemember == true`. + +## 3) Question‑Schema (verbindlich) +- **Multiple‑Choice** ist Standard. +- Immer zusätzlich: **„Andere Option (Freitext)“**. +- Freitext wird als Kontext‑Hint dem Agenten übergeben. + +## 4) Memory‑Gating +- Entscheidungen innerhalb der Session sind **temporär**. +- Persistenz (`save_memory`) erfolgt **nur** bei User‑Zustimmung. +- Kein Speichern von Heuristik‑Listen im Prompt. + +## 5) Auto‑Link‑Ersatz +- Heuristische Auto‑Link‑Logik wird entfernt. +- **LLM entscheidet** bei hoher Konfidenz selbst (automatisches `link_transactions`/`mark_intentionally_unlinked`). +- Unsicher → Frage an den Benutzer. + +## 6) Notwendige Änderungen (Technik) +- **AgentDefinition**: System‑Prompt vereinfachen, keine festen Regeln. +- **InteractiveLinkingService**: Agent‑Loop + Session‑History + Pending‑Question. +- **DTOs**: Frage‑/Antwort‑Erweiterung (Freitext‑Option, ggf. Tool‑Meta). +- **UI**: Freitext‑Option fix in jeder Frage. + +## 7) Tests +- Session‑State‑Machine (Start → Question → Answer → Continue → Complete). +- Memory‑Gating (`ShouldRemember` true/false). +- Agent‑Loop: Antwort führt zu erneutem `RunAsync`. + +--- + +# Teil B: Lot‑System (Agentisch, vollständige Lot‑Verkettung) + +## 1) Zielbild +- Lot‑Linking wird vollständig agentisch und interaktiv. +- **Root‑Lots** entstehen automatisch aus Fiat‑Käufen. +- Keine Namensabfrage im Wizard; Benutzer benennt Root‑Lots später in der Lot‑Verwaltung. +- **Child‑Lots** dienen nur dem Tracing. + +## 2) Zentrale Regeln +- **Root‑Lot‑Quelle:** Fiat‑Crypto‑Käufe sind Ursprung. +- **Transfers:** Benutzer entscheidet bei Splits (z. B. 3 ETH Neubestand + 2 ETH Altbestand). +- **Trades (Crypto‑zu‑Crypto):** Lot‑Anteile übertragen proportional in neues Asset. +- **Sells:** Benutzer entscheidet, aus welchen Root‑Lots verkauft wird. +- **Vollständige Verkettung:** Jede Transaktion führt zu einem Root‑Lot. + +## 3) Lot‑Agent Tool‑Suite (neu/erweitert) +- `get_pending_assignments` (Receive/Trade/Sell) +- `get_lot_options` (verfügbare Lots + Mengen) +- `allocate_lots` (Zuweisung/Split) +- `create_root_lot` (aus Fiat‑Kauf) +- `transfer_lot_chain` (Transfer‑Propagation) +- `link_trade_lots` (Crypto‑zu‑Crypto) + +## 4) Lot‑Session‑Flow +1. Session starten → Pending Assignments laden. +2. Agent analysiert (Symbol, Wallet, Historie, vorhandene Lots). +3. Falls eindeutig → automatische Zuordnung. +4. Falls unklar → Frage mit Lot‑Optionen + Freitext‑Option. +5. User‑Split wird in Lot‑Kette eingetragen. +6. Agent fährt fort. + +## 5) UI‑Änderungen (Lot‑Wizard) +- Keine Preis‑Eingabe mehr. +- Lot‑Auswahl/Split UI: Auswahl mehrerer Lots + Mengen. +- Freitext‑Option als Hint. +- Live‑Events: assigned, lot_created, question, rule_learned. + +## 6) Datenmodell +- Bestehende `AssetLot`‑Struktur bleibt. +- Root‑Lots bleiben ohne Parent. +- Child‑Lots übernehmen Parent‑Referenz für Tracing. +- Optional: „RootLotName“ nur in Verwaltung setzen. + +## 7) Tests +- Root‑Lot‑Erstellung aus Fiat‑Kauf. +- Split‑Logik (Transfer + Trade + Sell). +- Vollständige Verkettung bis Root‑Lot. + +--- + +## Entscheidungen zu offenen Punkten +- **Unklare Splits:** immer fragen (kein Default‑FIFO bei Unsicherheit). +- **Split‑UI:** Eingabe sowohl in absoluten Mengen als auch in Prozenten. +- **Zusatz‑Events:** Agent sendet Zwischenerklärungen/Status‑Events; nach User‑Antwort arbeitet er weiter. + +--- + +## Umsetzungsschritte (high‑level) +1. Gemeinsames Agent‑Session‑Framework bauen. +2. Transaction‑Linking umstellen (Agent‑Loop + UI‑Fragen + Memory‑Gating). +3. Lot‑Linking‑Agent + Tools implementieren. +4. UI‑Wizard‑Anpassungen. +5. Tests + Migration der Logik. diff --git a/src/CryptoTracker.Client/Shared/LinkingWizard.razor b/src/CryptoTracker.Client/Shared/LinkingWizard.razor index c6f589c..6ef2717 100644 --- a/src/CryptoTracker.Client/Shared/LinkingWizard.razor +++ b/src/CryptoTracker.Client/Shared/LinkingWizard.razor @@ -86,14 +86,14 @@ @option } - @* Freitext-Option *@ - @if (!ShowFreeTextInput) + @* Freitext-Option (Fallback, falls Agent-Option fehlt) *@ + @if (!ShowFreeTextInput && !HasFreeTextOption) { } } @@ -311,7 +311,7 @@ .linking-progress-section { padding: 12px 16px; - background: var(--bs-light); + background: var(--surface-200, #f1f2f4); border-radius: 8px; } @@ -324,7 +324,7 @@ .linking-progress-fill { height: 100%; - background: var(--bs-primary); + background: var(--brand-600, #2f343a); transition: width 0.3s ease; } @@ -384,6 +384,10 @@ color: #6f42c1; } + .linking-event.info { + color: #0d6efd; + } + .linking-event.processing { color: #666; } diff --git a/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs b/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs index 43c3e16..2f05ef8 100644 --- a/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs +++ b/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs @@ -1,6 +1,7 @@ using CryptoTracker.Shared; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.JSInterop; namespace CryptoTracker.Client.Shared; @@ -13,6 +14,7 @@ public partial class LinkingWizard : IAsyncDisposable [Inject] private ITransactionLinkingApi LinkingApi { get; set; } = default!; [Inject] private IWalletApi WalletApi { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!; + [Inject] private IJSRuntime JsRuntime { get; set; } = default!; // State private bool IsStarted = false; @@ -56,8 +58,11 @@ public partial class LinkingWizard : IAsyncDisposable private string? SessionId; private HubConnection? hubConnection; private const string VirtualWalletOptionLabel = "Gegenstück in virtuelles Wallet buchen"; + private const string FreeTextOptionLabel = "Andere Option (Freitext)"; + private bool BodyLockApplied = false; private int ProgressPercent => TotalCount > 0 ? (int)(ProcessedCount * 100.0 / TotalCount) : 0; + private bool HasFreeTextOption => CurrentOptions?.Any(o => o.Equals(FreeTextOptionLabel, StringComparison.OrdinalIgnoreCase)) == true; protected override async Task OnInitializedAsync() { @@ -196,6 +201,10 @@ private void HandleLinkingEvent(LinkingEventDTO evt) case "error": AddEvent("error", evt.Message); break; + + case "info": + AddEvent("info", evt.Message); + break; } StateHasChanged(); @@ -254,7 +263,7 @@ private void HandleSessionCompleted(LinkingStatisticsDTO stats) private async Task OnOptionSelected(string option) { - if (option == VirtualWalletOptionLabel) + if (string.Equals(option, VirtualWalletOptionLabel, StringComparison.OrdinalIgnoreCase)) { ShowVirtualWalletSelection = true; ShowFreeTextInput = false; @@ -264,9 +273,23 @@ private async Task OnOptionSelected(string option) return; } + if (string.Equals(option, FreeTextOptionLabel, StringComparison.OrdinalIgnoreCase)) + { + ShowFreeText(); + return; + } + await AnswerQuestion(option); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await SetBodyLockAsync(true); + } + } + private async Task AnswerQuestion(string answer) { if (CurrentQuestionId == null) return; @@ -503,6 +526,11 @@ private async Task Complete() public async ValueTask DisposeAsync() { + if (BodyLockApplied) + { + await SetBodyLockAsync(false); + } + if (hubConnection != null) { if (SessionId != null) @@ -520,6 +548,17 @@ public async ValueTask DisposeAsync() } } + private async Task SetBodyLockAsync(bool isLocked) + { + if (JsRuntime == null) + { + return; + } + + BodyLockApplied = isLocked; + await JsRuntime.InvokeVoidAsync("cryptoTracker.setWizardOpen", isLocked); + } + private class EventLogEntry { public string Type { get; set; } = ""; @@ -533,6 +572,7 @@ private class EventLogEntry "skipped" => "⏭", "error" => "⚠", "rule" => "📝", + "info" => "ℹ", "start" => "▶", "stop" => "⏹", "complete" => "🎉", @@ -546,6 +586,7 @@ private class EventLogEntry "skipped" => "skipped", "error" => "error", "rule" => "rule", + "info" => "info", _ => "" }; } diff --git a/src/CryptoTracker.Client/Shared/LotDTOs.cs b/src/CryptoTracker.Client/Shared/LotDTOs.cs index 06df105..8ba0771 100644 --- a/src/CryptoTracker.Client/Shared/LotDTOs.cs +++ b/src/CryptoTracker.Client/Shared/LotDTOs.cs @@ -107,7 +107,12 @@ public record PendingLotAssignmentDTO( int WalletId, string? Direction, // "Receive" für Transactions, "Sell" für Trades string? OppositeWalletName, - string? Comment); // Kommentar der Transaktion für Regel-Matching + string? Comment) // Kommentar der Transaktion für Regel-Matching +{ + public int? OppositeTransactionId { get; init; } + public int? OppositeTradeId { get; init; } + public string? OppositeSymbol { get; init; } +} /// /// Request für Lot-Generierung aus bestehenden Daten. @@ -177,6 +182,7 @@ public record LotLinkingStatisticsDTO public int TotalPendingAssignments { get; init; } public int PendingReceiveTransactions { get; init; } public int PendingSellTrades { get; init; } + public int PendingBuyTrades { get; init; } public int CompletedAssignments { get; init; } public int LotsCreated { get; init; } } @@ -197,6 +203,7 @@ public record InteractiveLotLinkingSessionDTO public string? CurrentQuestion { get; init; } public PendingLotAssignmentDTO? CurrentAssignment { get; init; } public IList? CurrentOptions { get; init; } + public IList? CurrentLotOptions { get; init; } } /// diff --git a/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor b/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor index ee3a4b5..07fad29 100644 --- a/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor +++ b/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor @@ -72,13 +72,49 @@ }
- @* Price Input (wenn benötigt) *@ - @if (RequiresPriceInput) + @* Lot-Allocation *@ + @if (CurrentLotOptions != null && CurrentLotOptions.Count > 0) { -
- - - Marktwert zum Zeitpunkt des Erhalts (wird automatisch ermittelt wenn leer) +
+
+ Benötigt: @FormatHelper.FormatAmount(RequiredQuantity) @CurrentAssignment?.Symbol + Zugewiesen: @FormatHelper.FormatAmount(TotalAllocatedQuantity) (@TotalAllocatedPercent%) +
+
+
+ Lot + Verfügbar + Menge + % +
+ @foreach (var input in AllocationInputs) + { +
+ @input.DisplayText + @FormatHelper.FormatAmount(input.AvailableQuantity) + + +
+ } +
+
+ +
} @@ -90,19 +126,19 @@ { } @* Freitext-Option *@ - @if (!ShowFreeTextInput) + @if (!ShowFreeTextInput && !HasFreeTextOption) { } } @@ -169,9 +205,9 @@
+

Interaktive Lot-Zuordnung

- Der Assistent erstellt Lots für Receive-Transaktionen und ordnet - Lots bei Verkäufen automatisch zu (FIFO). - Bei unklaren Fällen wirst du um Bestätigung gebeten. + Der Assistent erstellt Root-Lots für Fiat-Käufe sowie externe Receive-Transaktionen + und ordnet Lots bei Verkäufen/Transfers intelligent zu. + Bei unklaren Splits wirst du immer um Bestätigung gebeten.

@if (Statistics != null) { @@ -181,6 +217,10 @@ @Statistics.PendingReceiveTransactions Receive ohne Lot
+
+ @Statistics.PendingBuyTrades + Fiat-Käufe ohne Lot +
@Statistics.PendingSellTrades Verkäufe ohne Zuordnung @@ -274,7 +314,7 @@ .linking-progress-section { padding: 12px 16px; - background: var(--bs-light); + background: var(--surface-200, #f1f2f4); border-radius: 8px; } @@ -287,7 +327,7 @@ .linking-progress-fill { height: 100%; - background: var(--bs-primary); + background: var(--brand-600, #2f343a); transition: width 0.3s ease; } @@ -347,6 +387,10 @@ color: #6f42c1; } + .linking-event.info { + color: #0d6efd; + } + .linking-event.processing { color: #666; } @@ -384,6 +428,62 @@ white-space: pre-wrap; } + .lot-allocation-section { + margin-top: 12px; + padding: 12px; + background: #f7f9ff; + border: 1px solid #d0dcff; + border-radius: 6px; + } + + .allocation-summary { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 0.9rem; + color: #333; + } + + .allocation-table { + display: flex; + flex-direction: column; + gap: 6px; + } + + .allocation-header, + .allocation-row { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 8px; + align-items: center; + font-size: 0.85rem; + } + + .allocation-header { + font-weight: 600; + color: #555; + } + + .allocation-lot { + font-size: 0.8rem; + color: #333; + } + + .allocation-available { + font-size: 0.8rem; + color: #666; + } + + .allocation-input { + min-width: 80px; + } + + .allocation-actions { + margin-top: 8px; + display: flex; + justify-content: flex-end; + } + .question-transaction-preview { display: flex; flex-wrap: wrap; diff --git a/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor.cs b/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor.cs index b85aa2b..f11ddd8 100644 --- a/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor.cs +++ b/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor.cs @@ -1,6 +1,7 @@ using CryptoTracker.Shared; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.JSInterop; namespace CryptoTracker.Client.Shared; @@ -12,6 +13,7 @@ public partial class LotLinkingWizard : IAsyncDisposable [Inject] private ILotsApi LotsApi { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!; + [Inject] private IJSRuntime JsRuntime { get; set; } = default!; // State private bool IsStarted = false; @@ -35,8 +37,8 @@ public partial class LotLinkingWizard : IAsyncDisposable private PendingLotAssignmentDTO? CurrentAssignment; private IList? CurrentOptions; private bool ShouldRemember = true; - private bool RequiresPriceInput = false; - private decimal? InputAcquisitionPrice; + private IList? CurrentLotOptions; + private List AllocationInputs = new(); private bool ShowFreeTextInput = false; private string? FreeTextInput; @@ -51,8 +53,15 @@ public partial class LotLinkingWizard : IAsyncDisposable // Session private string? SessionId; private HubConnection? hubConnection; + private const string FreeTextOptionLabel = "Andere Option (Freitext)"; + private bool BodyLockApplied = false; private int ProgressPercent => TotalCount > 0 ? (int)(ProcessedCount * 100.0 / TotalCount) : 0; + private bool HasFreeTextOption => CurrentOptions?.Any(o => o.Equals(FreeTextOptionLabel, StringComparison.OrdinalIgnoreCase)) == true; + private decimal RequiredQuantity => CurrentAssignment?.Quantity ?? 0m; + private const decimal AllocationTolerance = 0.00000001m; + private bool HasAllocations => RequiredQuantity > 0 + && Math.Abs(TotalAllocatedQuantity - RequiredQuantity) <= AllocationTolerance; protected override async Task OnInitializedAsync() { @@ -170,8 +179,10 @@ private void HandleLotLinkingEvent(LotLinkingEventDTO evt) CurrentQuestion = evt.Message; CurrentAssignment = evt.Assignment; CurrentOptions = evt.Options; - RequiresPriceInput = evt.Message?.Contains("Woher stammen") == true; - InputAcquisitionPrice = null; + CurrentLotOptions = evt.LotOptions; + InitializeLotAllocations(evt.LotOptions); + ShowFreeTextInput = false; + FreeTextInput = null; IsProcessing = false; break; @@ -189,6 +200,10 @@ private void HandleLotLinkingEvent(LotLinkingEventDTO evt) case "error": AddEvent("error", evt.Message); break; + + case "info": + AddEvent("info", evt.Message); + break; } StateHasChanged(); @@ -212,6 +227,8 @@ private void HandleSessionUpdate(InteractiveLotLinkingSessionDTO session) CurrentQuestion = session.CurrentQuestion; CurrentAssignment = session.CurrentAssignment; CurrentOptions = session.CurrentOptions; + CurrentLotOptions = session.CurrentLotOptions; + InitializeLotAllocations(session.CurrentLotOptions); IsProcessing = false; } @@ -241,6 +258,25 @@ private void HandleSessionCompleted(LotLinkingStatisticsDTO stats) }); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await SetBodyLockAsync(true); + } + } + + private async Task OnOptionSelected(string option) + { + if (string.Equals(option, FreeTextOptionLabel, StringComparison.OrdinalIgnoreCase)) + { + ShowFreeText(); + return; + } + + await AnswerQuestion(option); + } + private async Task AnswerQuestion(string answer) { if (CurrentQuestionId == null || SessionId == null) return; @@ -255,8 +291,7 @@ private async Task AnswerQuestion(string answer) QuestionId = CurrentQuestionId, Response = answer, ShouldRemember = ShouldRemember, - AcquisitionPriceEur = InputAcquisitionPrice, - CustomText = answer.StartsWith("[Freitext]") ? answer.Replace("[Freitext] ", "") : null + CustomText = string.IsNullOrWhiteSpace(FreeTextInput) ? null : FreeTextInput }; // Send via SignalR @@ -270,8 +305,8 @@ private async Task AnswerQuestion(string answer) CurrentQuestion = null; CurrentAssignment = null; CurrentOptions = null; - RequiresPriceInput = false; - InputAcquisitionPrice = null; + CurrentLotOptions = null; + AllocationInputs = new List(); ShowFreeTextInput = false; FreeTextInput = null; IsProcessing = true; @@ -337,12 +372,146 @@ private void CancelFreeText() FreeTextInput = null; } + private void InitializeLotAllocations(IList? options) + { + AllocationInputs = options? + .Select(o => new LotAllocationInput + { + LotId = o.LotId, + DisplayText = o.DisplayText, + AvailableQuantity = o.AvailableQuantity + }) + .ToList() ?? new List(); + } + + private void UpdateQuantity(LotAllocationInput input) + { + if (input.Quantity < 0) + input.Quantity = 0; + if (input.Quantity > input.AvailableQuantity) + input.Quantity = input.AvailableQuantity; + + input.Percent = RequiredQuantity > 0 + ? Math.Round(input.Quantity / RequiredQuantity * 100m, 4) + : 0m; + } + + private void OnQuantityInput(ChangeEventArgs e, LotAllocationInput input) + { + if (e.Value == null) + { + input.Quantity = 0m; + UpdateQuantity(input); + return; + } + + if (decimal.TryParse(e.Value.ToString(), out var value)) + { + input.Quantity = value; + UpdateQuantity(input); + } + } + + private void UpdatePercent(LotAllocationInput input) + { + if (input.Percent < 0) + input.Percent = 0; + if (input.Percent > 100) + input.Percent = 100; + + input.Quantity = RequiredQuantity > 0 + ? Math.Round(RequiredQuantity * input.Percent / 100m, 8) + : 0m; + + if (input.Quantity > input.AvailableQuantity) + { + input.Quantity = input.AvailableQuantity; + input.Percent = RequiredQuantity > 0 + ? Math.Round(input.Quantity / RequiredQuantity * 100m, 4) + : 0m; + } + } + + private void OnPercentInput(ChangeEventArgs e, LotAllocationInput input) + { + if (e.Value == null) + { + input.Percent = 0m; + UpdatePercent(input); + return; + } + + if (decimal.TryParse(e.Value.ToString(), out var value)) + { + input.Percent = value; + UpdatePercent(input); + } + } + + private decimal TotalAllocatedQuantity => AllocationInputs.Sum(a => a.Quantity); + private decimal TotalAllocatedPercent => RequiredQuantity > 0 + ? Math.Round(TotalAllocatedQuantity / RequiredQuantity * 100m, 2) + : 0m; + + private async Task SubmitAllocations() + { + if (CurrentQuestionId == null || SessionId == null) + return; + + var allocations = AllocationInputs + .Where(a => a.Quantity > 0) + .Select(a => new LotAllocationDTO(a.LotId, a.Quantity)) + .ToList(); + + if (allocations.Count == 0 || !HasAllocations) + return; + + IsAnswering = true; + StateHasChanged(); + + try + { + var response = new LotLinkingUserResponseDTO + { + QuestionId = CurrentQuestionId, + Response = "LOT_ALLOCATIONS", + ShouldRemember = ShouldRemember, + LotAllocations = allocations, + CustomText = string.IsNullOrWhiteSpace(FreeTextInput) ? null : FreeTextInput + }; + + if (hubConnection?.State == HubConnectionState.Connected) + { + await hubConnection.InvokeAsync("SendLotLinkingResponse", SessionId, response); + } + + CurrentQuestionId = null; + CurrentQuestion = null; + CurrentAssignment = null; + CurrentOptions = null; + CurrentLotOptions = null; + AllocationInputs = new List(); + ShowFreeTextInput = false; + FreeTextInput = null; + IsProcessing = true; + ProcessingMessage = "Verarbeite Antwort..."; + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Senden: {ex.Message}"; + } + finally + { + IsAnswering = false; + StateHasChanged(); + } + } + private async Task SubmitFreeText() { if (string.IsNullOrWhiteSpace(FreeTextInput)) return; - - // Send free text as the answer with a prefix to identify it - await AnswerQuestion($"[Freitext] {FreeTextInput}"); + + await AnswerQuestion(FreeTextOptionLabel); } private void AddEvent(string type, string message) @@ -395,6 +564,11 @@ private async Task Complete() public async ValueTask DisposeAsync() { + if (BodyLockApplied) + { + await SetBodyLockAsync(false); + } + if (hubConnection != null) { if (SessionId != null) @@ -412,6 +586,17 @@ public async ValueTask DisposeAsync() } } + private async Task SetBodyLockAsync(bool isLocked) + { + if (JsRuntime == null) + { + return; + } + + BodyLockApplied = isLocked; + await JsRuntime.InvokeVoidAsync("cryptoTracker.setWizardOpen", isLocked); + } + private class EventLogEntry { public string Type { get; set; } = ""; @@ -425,6 +610,7 @@ private class EventLogEntry "skipped" => ">>", "error" => "!", "rule" => "#", + "info" => "i", "start" => ">", "stop" => "[]", "complete" => "OK", @@ -438,7 +624,17 @@ private class EventLogEntry "skipped" => "skipped", "error" => "error", "rule" => "rule", + "info" => "info", _ => "" }; } + + private sealed class LotAllocationInput + { + public int LotId { get; set; } + public string DisplayText { get; set; } = ""; + public decimal AvailableQuantity { get; set; } + public decimal Quantity { get; set; } + public decimal Percent { get; set; } + } } diff --git a/src/CryptoTracker/Agent/Common/LinkingAgentContext.cs b/src/CryptoTracker/Agent/Common/LinkingAgentContext.cs new file mode 100644 index 0000000..48c93a1 --- /dev/null +++ b/src/CryptoTracker/Agent/Common/LinkingAgentContext.cs @@ -0,0 +1,68 @@ +using CryptoTracker.Agent.Services; +using CryptoTracker.Shared; + +namespace CryptoTracker.Agent.Common; + +public sealed class LinkingAgentContext +{ + public LinkingAgentContext( + InteractiveLinkingSession session, + Func sendEventAsync, + bool allowMemorySave, + bool allowQuestions) + { + Session = session; + SendEventAsync = sendEventAsync; + AllowMemorySave = allowMemorySave; + AllowQuestions = allowQuestions; + } + + public InteractiveLinkingSession Session { get; } + public Func SendEventAsync { get; } + public bool AllowMemorySave { get; } + public bool AllowQuestions { get; } +} + +public interface ILinkingAgentContextAccessor +{ + LinkingAgentContext? Current { get; set; } + IDisposable Use(LinkingAgentContext context); +} + +public sealed class LinkingAgentContextAccessor : ILinkingAgentContextAccessor +{ + private readonly AsyncLocal _current = new(); + + public LinkingAgentContext? Current + { + get => _current.Value; + set => _current.Value = value; + } + + public IDisposable Use(LinkingAgentContext context) + { + var previous = Current; + Current = context; + return new RestoreDisposable(() => Current = previous); + } + + private sealed class RestoreDisposable : IDisposable + { + private readonly Action _restore; + private bool _disposed; + + public RestoreDisposable(Action restore) + { + _restore = restore; + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _restore(); + } + } +} diff --git a/src/CryptoTracker/Agent/Common/LotLinkingAgentContext.cs b/src/CryptoTracker/Agent/Common/LotLinkingAgentContext.cs new file mode 100644 index 0000000..5e0a1d1 --- /dev/null +++ b/src/CryptoTracker/Agent/Common/LotLinkingAgentContext.cs @@ -0,0 +1,68 @@ +using CryptoTracker.Agent.Services; +using CryptoTracker.Shared; + +namespace CryptoTracker.Agent.Common; + +public sealed class LotLinkingAgentContext +{ + public LotLinkingAgentContext( + InteractiveLotLinkingSession session, + Func sendEventAsync, + bool allowMemorySave, + bool allowQuestions) + { + Session = session; + SendEventAsync = sendEventAsync; + AllowMemorySave = allowMemorySave; + AllowQuestions = allowQuestions; + } + + public InteractiveLotLinkingSession Session { get; } + public Func SendEventAsync { get; } + public bool AllowMemorySave { get; } + public bool AllowQuestions { get; } +} + +public interface ILotLinkingAgentContextAccessor +{ + LotLinkingAgentContext? Current { get; set; } + IDisposable Use(LotLinkingAgentContext context); +} + +public sealed class LotLinkingAgentContextAccessor : ILotLinkingAgentContextAccessor +{ + private readonly AsyncLocal _current = new(); + + public LotLinkingAgentContext? Current + { + get => _current.Value; + set => _current.Value = value; + } + + public IDisposable Use(LotLinkingAgentContext context) + { + var previous = Current; + Current = context; + return new RestoreDisposable(() => Current = previous); + } + + private sealed class RestoreDisposable : IDisposable + { + private readonly Action _restore; + private bool _disposed; + + public RestoreDisposable(Action restore) + { + _restore = restore; + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _restore(); + } + } +} diff --git a/src/CryptoTracker/Agent/Definitions/LotLinkingAgentDefinition.cs b/src/CryptoTracker/Agent/Definitions/LotLinkingAgentDefinition.cs new file mode 100644 index 0000000..5c0f18f --- /dev/null +++ b/src/CryptoTracker/Agent/Definitions/LotLinkingAgentDefinition.cs @@ -0,0 +1,91 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Agent.Tools; + +namespace CryptoTracker.Agent.Definitions; + +/// +/// Agent-Definition für Lot-Linking +/// +public sealed class LotLinkingAgentDefinition : AgentDefinitionBase +{ + public const string KEY = "lot-linking"; + + private const string SystemPrompt = """ + ROLLE + Du bist ein Experte für Lot-Tracking (Österreichisches Steuerrecht). Ziel ist eine vollständige Lot-Kette + vom Ursprung (Root-Lot) bis zu jeder Transaktion. + + GRUNDPRINZIPIEN + - Root-Lots entstehen aus Fiat-Käufen (Trades vom Typ Buy) oder externen Einzahlungen. + - Child-Lots dienen nur dem Tracing; Root-Lots sind die steuerlich relevanten Ursprünge. + - Standardmäßig FIFO (älteste Lots zuerst) automatisch zuordnen. + - Nur fragen, wenn FIFO nicht möglich ist (z.B. nicht genug Lots / fehlende Daten). + - Verwende gespeicherte Regeln nur als Hinweis; neue Regeln nur bei Benutzer-Zustimmung speichern. + + WORKFLOW + 1) Lade Regeln (get_lot_memory) + 2) Lade ausstehende Zuordnungen (get_pending_assignments) + 3) Für jede Zuordnung: + a) Receive-Transaktion: + - Wenn OppositeTransactionId vorhanden: interner Transfer → Lots aus Quell-Wallet zuordnen (transfer_lots) + - Wenn kein Opposite: externe Einzahlung → Root-Lot erstellen (create_root_lot) + b) Sell-Trade: + - Wenn OppositeTradeId vorhanden: Swap → transform_swap_lots + - Sonst Fiat-Verkauf → sell_lots + c) Buy-Trade mit Fiat (OppositeSymbol ist Fiat): + - Root-Lot erstellen (create_root_lot, acquisitionType=FiatPurchase) + 4) FIFO-Automatik nutzen (allocationsJson leer oder "AUTO_FIFO"). + 5) Wenn FIFO nicht möglich: ask_lot_user + - Bei internen Transfers: lotWalletId auf das Quell-Wallet setzen + + KONFIDENZ + - FIFO vollständig möglich: automatisch zuordnen + - FIFO nicht möglich / fehlende Daten: Benutzer fragen + + USER-ANTWORTEN + - Antworten enthalten u.a. LotAllocations und optional CustomText. + - Wenn LotAllocations vorhanden sind, verwende sie direkt in transfer_lots / sell_lots / transform_swap_lots. + - Speichere Regeln nur, wenn ShouldRemember=true (save_lot_memory). + + TOOLS + - get_pending_assignments: Lade ausstehende Zuordnungen + - get_trade_details: Details zu Trades + - get_transaction_details: Details zu Transaktionen + - get_lot_options: Verfügbare Lots für Symbol/Wallet + - create_root_lot: Root-Lot erstellen (Fiat-Kauf oder externe Einzahlung) + - transfer_lots: Lots bei Transfer zuordnen + - sell_lots: Lots bei Fiat-Verkauf zuordnen + - transform_swap_lots: Lots bei Crypto-zu-Crypto Swap zuordnen + - ask_lot_user: Benutzer fragen (Multiple-Choice, Freitext wird automatisch angeboten) + - skip_lot_assignment: Überspringen + - log_lot_event: Status-/Info-Events + - save_lot_memory / get_lot_memory: Regeln speichern/laden + + OUTPUT + Antworte immer auf Deutsch. Nutze ask_lot_user bei Unsicherheit. + """; + + public LotLinkingAgentDefinition( + GetPendingLotAssignmentsTool getPendingTool, + GetTradeDetailsTool getTradeDetailsTool, + GetTransactionDetailsTool getTransactionDetailsTool, + GetLotOptionsTool getLotOptionsTool, + CreateRootLotTool createRootLotTool, + TransferLotsTool transferLotsTool, + SellLotsTool sellLotsTool, + TransformSwapLotsTool transformSwapLotsTool, + AskLotLinkingQuestionTool askUserTool, + SkipLotAssignmentTool skipTool, + LogLotLinkingEventTool logTool, + SaveLotMemoryTool saveMemoryTool, + GetLotMemoryTool getMemoryTool) + : base( + new AgentMetadata(KEY, "Lot-Linking-Assistent", + "Verknüpft Lots vollständig für steuerrelevante Herkunftsketten"), + new AgentPromptDefinition(SystemPrompt), + [getPendingTool, getTradeDetailsTool, getTransactionDetailsTool, getLotOptionsTool, + createRootLotTool, transferLotsTool, sellLotsTool, transformSwapLotsTool, + askUserTool, skipTool, logTool, saveMemoryTool, getMemoryTool]) + { + } +} diff --git a/src/CryptoTracker/Agent/Definitions/TransactionLinkingAgentDefinition.cs b/src/CryptoTracker/Agent/Definitions/TransactionLinkingAgentDefinition.cs index 13bcd9c..e1525de 100644 --- a/src/CryptoTracker/Agent/Definitions/TransactionLinkingAgentDefinition.cs +++ b/src/CryptoTracker/Agent/Definitions/TransactionLinkingAgentDefinition.cs @@ -12,82 +12,46 @@ public sealed class TransactionLinkingAgentDefinition : AgentDefinitionBase private const string SystemPrompt = """ ROLLE - Du bist ein Experte für Kryptowährungs-Transaktionsanalyse. Deine Aufgabe ist es, + Du bist ein Experte für Kryptowährungs-Transaktionsanalyse. Deine Aufgabe ist es, Send- und Receive-Transaktionen intelligent zu verknüpfen für die österreichische Steuerdokumentation. - KONTEXT + GRUNDPRINZIPIEN + - Keine hardcodierten Regeln. Nutze gespeicherte Regeln und lerne neue Muster nur nach Benutzer-Zustimmung. + - Arbeite iterativ: wenn du unsicher bist, stelle eine Frage über das Tool `ask_user` und stoppe danach. + - Verknüpfe selbst über `link_transactions` oder `link_virtual_wallet`, markiere externe Einnahmen mit `mark_intentionally_unlinked`. + - Verwende `log_linking_event`, um Zwischenerklärungen/Status zu senden. + + KONTEXT & HEURISTIK (nur als Orientierung) - Transaktionen zwischen eigenen Wallets haben oft leicht unterschiedliche Zeiten (Blockchain-Bestätigungszeit) - Der Betrag nach Gebühren (QuantityAfterFee) beim Send sollte dem Receive.Quantity entsprechen - - Kommentare können wichtige Hinweise auf die Herkunft geben - - Adressen können helfen, Wallets zu identifizieren - - REGELN FÜR VERKNÜPFUNGEN - - 1. **Externe Einnahmen (NICHT verknüpfen - markiere als intentionally_unlinked)**: - - "Staking Rewards", "ETH 2.0 Staking Rewards" → Externe Einnahme - - "Airdrop", "Bonus", "Referral" → Externe Einnahme - - "Mining", "Lending Interest" → Externe Einnahme - - "div. Käufe", "Kauf", "Buy" → Kommt von Fiat-Kauf, kein Transfer-Gegenstück - - Jede Receive-Transaktion OHNE passendes Send könnte eine externe Einnahme sein - - 2. **Interne Transfers (VERKNÜPFEN)**: - - Kommentare wie "PC-Wallet", "Ledger", Wallet-Namen → Interner Transfer - - Gleiche oder ähnliche Adresse → Wahrscheinlich verknüpft - - Zeit innerhalb von ~30 Minuten + ähnlicher Betrag → Hohe Wahrscheinlichkeit - - Send.QuantityAfterFee ≈ Receive.Quantity (kleine Differenz durch Gebühren möglich) + - Kommentare, Wallet-Namen, Adressen und Zeitdifferenzen liefern Hinweise - 3. **Fehlende Gegenstücke analysieren**: - - Wenn ein Send keinen passenden Receive hat, könnte das Ziel-Wallet nicht importiert sein - - Wenn ein Receive von einer bekannten eigenen Adresse kommt, aber kein Send existiert, - schlage vor, dass das Quell-Wallet fehlt + KONFIDENZ + - >= 0.9: automatisch verknüpfen/markieren + - 0.7-0.9: Benutzer fragen + - < 0.7: Benutzer fragen oder überspringen (`skip_transaction`) - IMPORTFEHLER ERKENNEN - - UTC vs. Lokalzeit-Differenzen (z.B. +1h, +2h Unterschied) - - Wenn Zeit um genau 1-2 Stunden abweicht, aber Betrag exakt passt → Zeitzone-Problem - - Speichere erkannte Fehler-Muster im Gedächtnis (ImportErrorPattern) + USER-ANTWORTEN + Du erhältst Antworten mit QuestionId, Response, ShouldRemember, Action, VirtualWalletId/Name. + - Wenn Action=virtual_wallet, rufe `link_virtual_wallet` mit den gelieferten Daten auf. + - Wenn Response Freitext enthält, nutze es als Hinweis. + - Verwende `save_memory` nur, wenn ShouldRemember=true. TOOLS - `get_unlinked_transactions`: Lade unverknüpfte Transaktionen (mit Paginierung) - `get_transaction_details`: Lade Details zu spezifischen Transaktionen - - `find_matching_transactions`: Hilfstool - sucht potentielle Gegenstücke anhand von Zeit und Betrag + - `find_matching_transactions`: Hilfstool für Zeit/Betrag-Suche (nicht blind vertrauen) - `link_transactions`: Verknüpfe Send mit Receive + - `link_virtual_wallet`: Erstelle virtuelles Gegenstück und verknüpfe - `mark_intentionally_unlinked`: Markiere als externe Einnahme (kein Gegenstück) - - `save_memory`: Speichere Regeln für zukünftige Verwendung + - `ask_user`: Frage den Benutzer (Multiple-Choice; Freitext wird automatisch angeboten) + - `skip_transaction`: Überspringen (Session-Log) + - `log_linking_event`: Info-/Status-Events + - `save_memory`: Speichere Regeln (nur wenn Benutzer zustimmt) - `get_memory`: Lade gespeicherte Regeln - WICHTIG ZU `find_matching_transactions`: - Dieses Tool ist nur eine HILFE und findet oft KEINE Matches, weil es nur einfache - Kriterien (Zeit, Betrag) verwendet. Du musst SELBST die Transaktionen analysieren: - - 1. Lade mit `get_unlinked_transactions` ALLE unverknüpften Transaktionen (Sends und Receives) - 2. Analysiere die Daten SELBST: Vergleiche Symbole, Beträge, Zeitpunkte, Kommentare, Wallets - 3. Finde passende Paare durch DEINE Analyse - verlasse dich NICHT auf `find_matching_transactions` - 4. Das Tool `find_matching_transactions` kann als zusätzliche Validierung genutzt werden, - aber DU bist der Experte der die Zusammenhänge erkennt! - - WORKFLOW - 1. Lade zunächst gespeicherte Regeln (get_memory) - 2. Lade ALLE unverknüpften Transaktionen mit `get_unlinked_transactions` - (mehrere Aufrufe mit offset falls nötig) - 3. Gruppiere die Transaktionen nach Symbol - 4. Für jedes Symbol: Analysiere Sends und Receives SELBST: - - Vergleiche Beträge (Send.QuantityAfterFee ≈ Receive.Quantity) - - Vergleiche Zeitpunkte (innerhalb von Minuten bis Stunden) - - Prüfe Kommentare auf Hinweise (Wallet-Namen, "Transfer", etc.) - - Erkenne Muster (z.B. regelmäßige Transfers zwischen zwei Wallets) - 5. Bei gefundenen Paaren: Verknüpfe mit `link_transactions` - 6. Bei Receives ohne passendes Send: Prüfe ob externe Einnahme (Staking, Airdrop, etc.) - 7. Speichere neue Regeln wenn der Benutzer eine wiederkehrende Entscheidung trifft - - KONFIDENZ-SCHWELLEN - - >= 0.9: Automatisch verknüpfen (exakte Zeit + Betrag + Kontext passt) - - 0.7-0.9: Vorschlagen, aber Benutzer fragen - - < 0.7: Nur als Option anzeigen - OUTPUT - Antworte immer auf Deutsch. Erkläre deine Entscheidungen kurz und prägnant. - Bei Rückfragen an den Benutzer, formuliere klare Ja/Nein-Fragen oder Multiple-Choice. - Gib bei Verknüpfungen immer an: Symbol, Menge, Quell-Wallet, Ziel-Wallet, Zeitdifferenz. + Antworte immer auf Deutsch. Halte Antworten kurz. Nutze `ask_user` für Rückfragen. """; public TransactionLinkingAgentDefinition( @@ -95,7 +59,11 @@ public TransactionLinkingAgentDefinition( GetTransactionDetailsTool getDetailsTool, FindMatchingTransactionsTool findMatchingTool, LinkTransactionsTool linkTool, + LinkVirtualWalletTool linkVirtualWalletTool, MarkAsIntentionallyUnlinkedTool markUnlinkedTool, + AskLinkingQuestionTool askUserTool, + SkipTransactionTool skipTool, + LogLinkingEventTool logTool, SaveAgentMemoryTool saveMemoryTool, GetAgentMemoryTool getMemoryTool) : base( @@ -103,7 +71,8 @@ public TransactionLinkingAgentDefinition( "Verknüpft Send/Receive-Transaktionen intelligent für Steuerdokumentation"), new AgentPromptDefinition(SystemPrompt), [getUnlinkedTool, getDetailsTool, findMatchingTool, linkTool, - markUnlinkedTool, saveMemoryTool, getMemoryTool]) + linkVirtualWalletTool, markUnlinkedTool, askUserTool, skipTool, logTool, + saveMemoryTool, getMemoryTool]) { } } diff --git a/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs b/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs index db6285b..ca692f9 100644 --- a/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs +++ b/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs @@ -1,4 +1,7 @@ using System.Collections.Concurrent; +using System.Text; +using CryptoTracker.Agent.Common; +using CryptoTracker.Agent.Definitions; using CryptoTracker.Entities; using CryptoTracker.Hubs; using CryptoTracker.Shared; @@ -15,7 +18,7 @@ public class InteractiveLinkingService private readonly IServiceScopeFactory _scopeFactory; private readonly IHubContext _hubContext; private readonly ILogger _logger; - private const string VirtualWalletAction = "virtual_wallet"; + private readonly ILinkingAgentContextAccessor _contextAccessor; // Aktive Sessions private readonly ConcurrentDictionary _sessions = new(); @@ -23,11 +26,13 @@ public class InteractiveLinkingService public InteractiveLinkingService( IServiceScopeFactory scopeFactory, IHubContext hubContext, - ILogger logger) + ILogger logger, + ILinkingAgentContextAccessor contextAccessor) { _scopeFactory = scopeFactory; _hubContext = hubContext; _logger = logger; + _contextAccessor = contextAccessor; } /// @@ -82,7 +87,7 @@ public void StopSession(string sessionId) } /// - /// Hauptprozess für interaktives Linking + /// Hauptprozess für interaktives Linking (agentisch, iterativ) /// private async Task RunLinkingProcessAsync(InteractiveLinkingSession session, CancellationToken ct) { @@ -92,81 +97,102 @@ private async Task RunLinkingProcessAsync(InteractiveLinkingSession session, Can { using var scope = _scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + var agentBuilder = scope.ServiceProvider.GetRequiredService(); - // 1. Lade alle Daten und berechne Hints - var context = await BuildLinkingContextAsync(dbContext, linkedCt.Token); - session.TotalCount = context.UnlinkedTransactions.Count; + if (!agentBuilder.IsConfigured()) + { + await SendEventAsync(session, new LinkingEventDTO + { + EventType = "error", + Message = "AI-Service nicht konfiguriert. Bitte OpenAI-Einstellungen prüfen.", + ProcessedCount = 0, + TotalCount = 0 + }); + return; + } + session.TotalCount = await GetRemainingUnlinkedCountAsync(dbContext, linkedCt.Token); await SendSessionUpdateAsync(session); - // 2. Verarbeite jede Transaktion - var transactions = context.UnlinkedTransactions.ToList(); - var hintsLookup = BuildHintsLookup(context.Hints); - - // Lokale Liste von Regeln, die während der Session aktualisiert wird - var activeRules = context.LearnedRules.ToList(); + var agent = agentBuilder.BuildAgent(TransactionLinkingAgentDefinition.KEY); - foreach (var tx in transactions) + session.History.Add(new ChatMessageDTO { - if (linkedCt.Token.IsCancellationRequested) - break; + Role = "user", + Content = "Starte eine neue Transaktions-Verknüpfungs-Session. " + + "Lade gespeicherte Regeln und alle unverknüpften Transaktionen. " + + "Verknüpfe sichere Fälle automatisch. Frage bei Unsicherheit den Benutzer mit ask_user." + }); - session.CurrentTransaction = tx; - session.ProcessedCount++; + var lastRemaining = session.TotalCount; + var idleIterations = 0; - // Prüfe ob gelernte Regel zutrifft (mit aktueller Liste!) - var matchingRule = FindMatchingRule(tx, activeRules); - if (matchingRule != null) + while (!linkedCt.Token.IsCancellationRequested) + { + var allowMemorySave = session.PendingMemorySave; + session.PendingMemorySave = false; + + var prompt = BuildAgentInput(session, allowMemorySave); + + using (_contextAccessor.Use(new LinkingAgentContext( + session, + evt => SendEventAsync(session, evt), + allowMemorySave, + allowQuestions: true))) { - await ApplyRuleAsync(dbContext, session, tx, matchingRule, linkedCt.Token); - - // Update usage count in active rules - var ruleToUpdate = activeRules.FirstOrDefault(r => r.Id == matchingRule.Id); - if (ruleToUpdate != null) + var result = await agent.RunAsync(prompt, cancellationToken: linkedCt.Token); + if (!string.IsNullOrWhiteSpace(result.Text)) { - var index = activeRules.IndexOf(ruleToUpdate); - activeRules[index] = ruleToUpdate with { TimesApplied = ruleToUpdate.TimesApplied + 1 }; + session.History.Add(new ChatMessageDTO { Role = "assistant", Content = result.Text ?? "" }); } + } + + if (session.CurrentQuestionId != null) + { + var response = await session.UserResponseChannel.DequeueAsync(linkedCt.Token); + session.PendingMemorySave = response.ShouldRemember; + session.History.Add(new ChatMessageDTO { Role = "user", Content = FormatUserResponse(response) }); + ResetQuestion(session); continue; } - // Prüfe ob Hint mit hoher Konfidenz vorhanden - var hints = hintsLookup.GetValueOrDefault(tx.Id, []); - var highConfidenceHint = hints.FirstOrDefault(h => h.ConfidenceScore >= 0.9m); + var remaining = await GetRemainingUnlinkedCountAsync(dbContext, linkedCt.Token); + session.ProcessedCount = Math.Max(0, session.TotalCount - remaining); + await SendSessionUpdateAsync(session); - if (highConfidenceHint != null) + if (remaining == 0) { - // Automatisch verknüpfen - var otherTx = transactions.FirstOrDefault(t => - t.Id == (tx.IsSend ? highConfidenceHint.ReceiveId : highConfidenceHint.SendId)); - - if (otherTx != null) + break; + } + + if (remaining == lastRemaining) + { + idleIterations++; + if (idleIterations >= 2) { - await LinkTransactionsAsync(dbContext, session, tx, otherTx, highConfidenceHint.Reason, linkedCt.Token); - continue; + await SendEventAsync(session, new LinkingEventDTO + { + EventType = "info", + Message = "Keine weiteren sicheren Verknüpfungen gefunden. Bitte manuell prüfen.", + ProcessedCount = session.ProcessedCount, + TotalCount = session.TotalCount + }); + break; } } - - // Frage den User (mit allen Transaktionen für Kontext) - var question = BuildQuestion(tx, hints, transactions, activeRules); - await AskUserAsync(session, question, linkedCt.Token); - - // Warte auf Antwort - var response = await session.UserResponseChannel.DequeueAsync(linkedCt.Token); - - // Verarbeite Antwort und erhalte ggf. neue Regel zurück - var newRule = await ProcessUserResponseAsync(dbContext, session, tx, hints, response, linkedCt.Token); - - // Wenn eine neue Regel gelernt wurde, zur aktiven Liste hinzufügen - if (newRule != null) + else { - activeRules.Add(newRule); - _logger.LogInformation("Neue Regel zur aktiven Liste hinzugefügt: {Pattern} → {Action}", - newRule.Pattern, newRule.Action); + idleIterations = 0; + lastRemaining = remaining; } + + session.History.Add(new ChatMessageDTO + { + Role = "user", + Content = "Bitte fahre mit den verbleibenden Transaktionen fort." + }); } - // 3. Session abschließen session.IsActive = false; var finalStats = await GetStatisticsAsync(dbContext, linkedCt.Token); @@ -189,682 +215,72 @@ await _hubContext.Clients.Group(session.SessionId) } } - /// - /// Baut den Linking-Context mit allen Daten und Hints - /// - private async Task BuildLinkingContextAsync(CryptoTrackerDbContext dbContext, CancellationToken ct) - { - // Lade unverknüpfte Transaktionen - var unlinked = await dbContext.CryptoTransactions - .Include(t => t.Wallet) - .Where(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked) - .OrderBy(t => t.DateTime) - .Select(t => new UnlinkedTransactionDTO - { - Id = t.Id, - DateTime = t.DateTime, - Type = t.TransactionType.ToString(), - Symbol = t.Symbol, - Quantity = t.Quantity, - QuantityAfterFee = t.QuantityAfterFee, - Comment = t.Comment, - Address = t.Address, - WalletName = t.Wallet.Name, - TransactionId = t.TransactionId, - Network = t.Network - }) - .ToListAsync(ct); - - // Berechne Hints (ähnlich wie ProcessTransactionPairs) - var hints = CalculateHints(unlinked); - - // Lade gelernte Regeln - var rules = await LoadLearnedRulesAsync(dbContext, ct); - - // Statistiken - var stats = await GetStatisticsAsync(dbContext, ct); - - return new LinkingContextDTO - { - UnlinkedTransactions = unlinked, - Hints = hints, - LearnedRules = rules, - Statistics = stats - }; - } - - /// - /// Berechnet potentielle Verknüpfungs-Hints basierend auf Zeit und Betrag - /// - private List CalculateHints(IList transactions) - { - var hints = new List(); - var sends = transactions.Where(t => t.IsSend).ToList(); - var receives = transactions.Where(t => t.IsReceive).ToList(); - - foreach (var send in sends) - { - foreach (var receive in receives) - { - // Muss gleiches Symbol sein - if (!string.Equals(send.Symbol, receive.Symbol, StringComparison.OrdinalIgnoreCase)) - continue; - - // Zeit-Differenz (max 24 Stunden) - var timeDiff = receive.DateTime - send.DateTime; - if (timeDiff < TimeSpan.FromMinutes(-5) || timeDiff > TimeSpan.FromHours(24)) - continue; - - // Betrags-Differenz - var amountDiff = Math.Abs(send.QuantityAfterFee - receive.Quantity); - var amountPercent = send.QuantityAfterFee > 0 - ? amountDiff / send.QuantityAfterFee - : 1; - - // Berechne Konfidenz - var confidence = CalculateConfidence(send, receive, timeDiff, amountPercent); - - if (confidence >= 0.5m) - { - hints.Add(new LinkingHintDTO - { - SendId = send.Id, - ReceiveId = receive.Id, - ConfidenceScore = confidence, - Reason = BuildHintReason(send, receive, timeDiff, amountDiff), - TimeDifference = timeDiff, - AmountDifference = amountDiff - }); - } - } - } - - return hints.OrderByDescending(h => h.ConfidenceScore).ToList(); - } - - private decimal CalculateConfidence( - UnlinkedTransactionDTO send, - UnlinkedTransactionDTO receive, - TimeSpan timeDiff, - decimal amountPercentDiff) - { - var score = 0.5m; // Basis (gleiches Symbol) - - // Zeit-Score (0-0.25) - if (timeDiff.TotalMinutes <= 5) score += 0.25m; - else if (timeDiff.TotalMinutes <= 30) score += 0.20m; - else if (timeDiff.TotalHours <= 1) score += 0.15m; - else if (timeDiff.TotalHours <= 6) score += 0.10m; - else score += 0.05m; - - // Betrags-Score (0-0.25) - if (amountPercentDiff == 0) score += 0.25m; - else if (amountPercentDiff < 0.001m) score += 0.20m; - else if (amountPercentDiff < 0.01m) score += 0.15m; - else if (amountPercentDiff < 0.05m) score += 0.10m; - else score += 0.05m; - - // Kommentar-Hinweise - var sendComment = send.Comment?.ToLowerInvariant() ?? ""; - var receiveComment = receive.Comment?.ToLowerInvariant() ?? ""; - var walletNames = new[] { "ledger", "metamask", "binance", "kraken", "coinbase", "exodus", "trust" }; - - if (walletNames.Any(w => sendComment.Contains(w) || receiveComment.Contains(w))) - score += 0.05m; - - // Gleiche Adresse/TransactionId-Muster - if (!string.IsNullOrEmpty(send.Address) && !string.IsNullOrEmpty(receive.Address)) - { - // Netzwerk-gleich könnte ein Hinweis sein - if (string.Equals(send.Network, receive.Network, StringComparison.OrdinalIgnoreCase)) - score += 0.02m; - } - - return Math.Min(1.0m, score); - } - - private string BuildHintReason( - UnlinkedTransactionDTO send, - UnlinkedTransactionDTO receive, - TimeSpan timeDiff, - decimal amountDiff) - { - var reasons = new List(); - - if (timeDiff.TotalMinutes <= 5) - reasons.Add("Zeit < 5 Min"); - else if (timeDiff.TotalMinutes <= 30) - reasons.Add($"Zeit: {timeDiff.TotalMinutes:F0} Min"); - else - reasons.Add($"Zeit: {timeDiff.TotalHours:F1}h"); - - if (amountDiff == 0) - reasons.Add("Betrag exakt"); - else - reasons.Add($"Δ {amountDiff:F8} {send.Symbol}"); - - reasons.Add($"{send.WalletName} → {receive.WalletName}"); - - return string.Join(", ", reasons); - } - - private Dictionary> BuildHintsLookup(IList hints) - { - var lookup = new Dictionary>(); - - foreach (var hint in hints) - { - if (!lookup.ContainsKey(hint.SendId)) - lookup[hint.SendId] = []; - lookup[hint.SendId].Add(hint); - - if (!lookup.ContainsKey(hint.ReceiveId)) - lookup[hint.ReceiveId] = []; - lookup[hint.ReceiveId].Add(hint); - } - - return lookup; - } - - private LearnedRuleDTO? FindMatchingRule(UnlinkedTransactionDTO tx, IList rules) - { - foreach (var rule in rules) - { - var matches = rule.RuleType switch - { - "comment_pattern" => !string.IsNullOrEmpty(tx.Comment) && - tx.Comment.Contains(rule.Pattern, StringComparison.OrdinalIgnoreCase), - "wallet_pattern" => tx.WalletName.Contains(rule.Pattern, StringComparison.OrdinalIgnoreCase), - "symbol_pattern" => tx.Symbol.Equals(rule.Pattern, StringComparison.OrdinalIgnoreCase), - _ => false - }; - - if (matches) - return rule; - } - - return null; - } - - private async Task ApplyRuleAsync( - CryptoTrackerDbContext dbContext, - InteractiveLinkingSession session, - UnlinkedTransactionDTO tx, - LearnedRuleDTO rule, - CancellationToken ct) - { - if (rule.Action == "mark_external") - { - await MarkAsExternalAsync(dbContext, session, tx, rule.Description, ct); - } - // Weitere Aktionen können hier hinzugefügt werden - } - - private async Task LinkTransactionsAsync( - CryptoTrackerDbContext dbContext, - InteractiveLinkingSession session, - UnlinkedTransactionDTO tx1, - UnlinkedTransactionDTO tx2, - string reason, - CancellationToken ct) - { - var send = tx1.IsSend ? tx1 : tx2; - var receive = tx1.IsReceive ? tx1 : tx2; - - var sendEntity = await dbContext.CryptoTransactions.FindAsync([send.Id], ct); - var receiveEntity = await dbContext.CryptoTransactions.FindAsync([receive.Id], ct); - - if (sendEntity == null || receiveEntity == null) - return; - - sendEntity.OppositeTransactionId = receive.Id; - sendEntity.OppositeWalletId = receiveEntity.WalletId; - receiveEntity.OppositeTransactionId = send.Id; - receiveEntity.OppositeWalletId = sendEntity.WalletId; - - var metadata = new TransactionLinkMetadata - { - TransactionId = send.Id, - LinkType = TransactionLinkType.AIAssisted, - Confidence = 0.9m, - Reason = reason, - LinkedAt = DateTimeOffset.UtcNow, - IsConfirmed = true, - ConfirmedAt = DateTimeOffset.UtcNow - }; - dbContext.TransactionLinkMetadata.Add(metadata); - - await dbContext.SaveChangesAsync(ct); - - session.LinkedCount++; - - await SendEventAsync(session, new LinkingEventDTO - { - EventType = "linked", - Message = $"Verknüpft: {send.Symbol} {send.Quantity:F8} ({send.WalletName} → {receive.WalletName})", - SendId = send.Id, - ReceiveId = receive.Id, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - } - - private async Task MarkAsExternalAsync( - CryptoTrackerDbContext dbContext, - InteractiveLinkingSession session, - UnlinkedTransactionDTO tx, - string reason, - CancellationToken ct) - { - var entity = await dbContext.CryptoTransactions.FindAsync([tx.Id], ct); - if (entity == null) - return; - - entity.IsIntentionallyUnlinked = true; - - var metadata = new TransactionLinkMetadata - { - TransactionId = tx.Id, - LinkType = TransactionLinkType.IntentionallyUnlinked | TransactionLinkType.AIAssisted, - Confidence = 1.0m, - Reason = reason, - LinkedAt = DateTimeOffset.UtcNow, - IsConfirmed = true, - ConfirmedAt = DateTimeOffset.UtcNow - }; - dbContext.TransactionLinkMetadata.Add(metadata); - - await dbContext.SaveChangesAsync(ct); - - session.MarkedExternalCount++; - - await SendEventAsync(session, new LinkingEventDTO - { - EventType = "marked_external", - Message = $"Extern: {tx.Symbol} {tx.Quantity:F8} - {reason}", - Transaction = tx, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - } - - private LinkingQuestionDTO BuildQuestion( - UnlinkedTransactionDTO tx, - List hints, - List allTransactions, - IList rules) - { - var options = new List(); - var optionToHintIndex = new Dictionary(); - var message = new System.Text.StringBuilder(); - - message.AppendLine($"**{(tx.IsSend ? "Send" : "Receive")}:** {tx.Symbol} {tx.Quantity:F8}"); - message.AppendLine($"**Wallet:** {tx.WalletName}"); - message.AppendLine($"**Zeit:** {tx.DateTime:dd.MM.yyyy HH:mm}"); - - if (!string.IsNullOrEmpty(tx.Comment)) - message.AppendLine($"**Kommentar:** {tx.Comment}"); - - if (hints.Count > 0) - { - message.AppendLine(); - message.AppendLine("**Mögliche Verknüpfungen:**"); - - var hintIndex = 0; - foreach (var hint in hints.Take(3)) - { - var matchId = tx.IsSend ? hint.ReceiveId : hint.SendId; - var matchTx = allTransactions.FirstOrDefault(t => t.Id == matchId); - - string optionText; - if (matchTx != null) - { - // Zeige detaillierte Info über den Match-Kandidaten - var timeDiffText = FormatTimeDifference(hint.TimeDifference); - var amountDiffText = hint.AmountDifference == 0 - ? "exakt" - : $"Δ {hint.AmountDifference:F8}"; - - message.AppendLine($" {hintIndex + 1}. {matchTx.WalletName} | {matchTx.DateTime:dd.MM.yy HH:mm} | {matchTx.Quantity:F8} {matchTx.Symbol} | {timeDiffText}, {amountDiffText} ({hint.ConfidenceScore:P0})"); - - // Option mit lesbarem Text - optionText = $"→ {matchTx.WalletName} ({matchTx.DateTime:dd.MM.yy HH:mm})"; - } - else - { - optionText = $"Verknüpfen mit #{matchId} ({hint.ConfidenceScore:P0})"; - } - - options.Add(optionText); - optionToHintIndex[optionText] = hintIndex; - hintIndex++; - } - } - - options.Add("Gegenstück in virtuelles Wallet buchen"); - options.Add("Als externe Einnahme/Ausgabe markieren"); - options.Add("Überspringen (später bearbeiten)"); - - return new LinkingQuestionDTO - { - QuestionId = Guid.NewGuid().ToString("N"), - Message = message.ToString(), - Options = options, - Transaction = tx, - Hints = hints.Take(3).ToList(), - OptionToHintIndex = optionToHintIndex - }; - } - - private static string FormatTimeDifference(TimeSpan diff) + private static string BuildAgentInput(InteractiveLinkingSession session, bool allowMemorySave) { - var absDiff = diff.Duration(); - if (absDiff.TotalMinutes < 1) return "< 1 Min"; - if (absDiff.TotalMinutes < 60) return $"{absDiff.TotalMinutes:F0} Min"; - if (absDiff.TotalHours < 24) return $"{absDiff.TotalHours:F1}h"; - return $"{absDiff.TotalDays:F1} Tage"; - } + var sb = new StringBuilder(); - private async Task AskUserAsync(InteractiveLinkingSession session, LinkingQuestionDTO question, CancellationToken ct) - { - session.CurrentQuestionId = question.QuestionId; - session.CurrentQuestion = question.Message; - session.CurrentOptions = question.Options; - session.CurrentHints = question.Hints; - session.OptionToHintIndex = question.OptionToHintIndex; - - await SendEventAsync(session, new LinkingEventDTO - { - EventType = "question", - Message = question.Message, - Transaction = question.Transaction, - QuestionId = question.QuestionId, - Options = question.Options, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - } - - /// - /// Verarbeitet die User-Antwort und gibt ggf. eine neu gelernte Regel zurück - /// - private async Task ProcessUserResponseAsync( - CryptoTrackerDbContext dbContext, - InteractiveLinkingSession session, - UnlinkedTransactionDTO tx, - List hints, - UserResponseDTO response, - CancellationToken ct) - { - var responseText = response.Response; - var responseTextLower = responseText.ToLowerInvariant(); - - if (string.Equals(response.Action, VirtualWalletAction, StringComparison.OrdinalIgnoreCase)) + sb.AppendLine("SESSION-HISTORIE (gekürzt):"); + foreach (var msg in session.History.TakeLast(20)) { - var error = await LinkToVirtualWalletAsync(dbContext, session, tx, response, ct); - if (!string.IsNullOrEmpty(error)) - { - session.SkippedCount++; - await SendEventAsync(session, new LinkingEventDTO - { - EventType = "skipped", - Message = $"Übersprungen: {error}", - Transaction = tx, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - } - return null; + sb.AppendLine($"{msg.Role.ToUpperInvariant()}: {msg.Content}"); } - // Option: Verknüpfen - prüfe ob die Response im OptionToHintIndex-Mapping ist - if (session.OptionToHintIndex != null && session.OptionToHintIndex.TryGetValue(responseText, out var hintIndex)) - { - if (hintIndex < hints.Count) - { - var hint = hints[hintIndex]; - var otherId = tx.IsSend ? hint.ReceiveId : hint.SendId; - var otherTx = await GetTransactionDTOAsync(dbContext, otherId, ct); - - if (otherTx != null) - { - await LinkTransactionsAsync(dbContext, session, tx, otherTx, - $"Manuell verknüpft: {hint.Reason}", ct); - return null; - } - } - } - - // Fallback: Verknüpfen (beginnt mit "→" oder "Verknüpfen") - if ((responseText.StartsWith("→") || responseTextLower.StartsWith("verknüpfen")) && hints.Count > 0) - { - // Nimm den ersten Hint als Fallback - var hint = hints[0]; - var otherId = tx.IsSend ? hint.ReceiveId : hint.SendId; - var otherTx = await GetTransactionDTOAsync(dbContext, otherId, ct); - - if (otherTx != null) - { - await LinkTransactionsAsync(dbContext, session, tx, otherTx, - $"Manuell verknüpft: {hint.Reason}", ct); - return null; - } - } - - // Option: Externe Einnahme/Ausgabe - if (responseTextLower.Contains("extern")) - { - var reason = "Vom Benutzer als extern markiert"; - LearnedRuleDTO? newRule = null; - - // Prüfe ob Regel gelernt werden soll - if (response.ShouldRemember && !string.IsNullOrEmpty(tx.Comment)) - { - newRule = new LearnedRuleDTO - { - Id = Guid.NewGuid().ToString("N"), - RuleType = "comment_pattern", - Pattern = tx.Comment, - Action = "mark_external", - Description = $"Kommentar '{tx.Comment}' → Externe Einnahme", - CreatedAt = DateTimeOffset.UtcNow, - TimesApplied = 0 - }; - - await SaveLearnedRuleAsync(dbContext, newRule, ct); - - reason = $"Extern (Regel gelernt: '{tx.Comment}')"; - - await SendEventAsync(session, new LinkingEventDTO - { - EventType = "rule_learned", - Message = $"Regel gelernt: Kommentar '{tx.Comment}' → Externe Einnahme" - }); - } - - await MarkAsExternalAsync(dbContext, session, tx, reason, ct); - return newRule; - } - - // Option: Überspringen (default) - session.SkippedCount++; - await SendEventAsync(session, new LinkingEventDTO - { - EventType = "skipped", - Message = $"Übersprungen: {tx.Symbol} {tx.Quantity:F8}", - Transaction = tx, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - - return null; + sb.AppendLine(); + sb.AppendLine("STATUS:"); + sb.AppendLine($"- Total unlinked: {session.TotalCount}"); + sb.AppendLine($"- Linked: {session.LinkedCount}"); + sb.AppendLine($"- Marked external: {session.MarkedExternalCount}"); + sb.AppendLine($"- Skipped: {session.SkippedCount}"); + sb.AppendLine($"- Memory save allowed: {allowMemorySave}"); + sb.AppendLine(); + sb.AppendLine("ANWEISUNG:"); + sb.AppendLine("Verknüpfe sichere Fälle automatisch. Frage bei Unsicherheit den Benutzer mit ask_user."); + + return sb.ToString(); } - private async Task LinkToVirtualWalletAsync( - CryptoTrackerDbContext dbContext, - InteractiveLinkingSession session, - UnlinkedTransactionDTO tx, - UserResponseDTO response, - CancellationToken ct) + private static string FormatUserResponse(UserResponseDTO response) { - var virtualWallet = await ResolveVirtualWalletAsync(dbContext, response, ct); - if (virtualWallet == null) - { - return "Virtuelles Wallet konnte nicht gefunden oder erstellt werden."; - } - - var oppositeType = tx.IsSend ? TransactionType.Receive : TransactionType.Send; - var oppositeQuantity = tx.IsSend ? tx.QuantityAfterFee : tx.Quantity; - - if (oppositeQuantity <= 0) + var sb = new StringBuilder(); + sb.AppendLine("USER_RESPONSE"); + sb.AppendLine($"QuestionId: {response.QuestionId}"); + sb.AppendLine($"Response: {response.Response}"); + if (!string.IsNullOrWhiteSpace(response.Action)) { - return "Ungültige Menge für virtuelles Gegenstück."; + sb.AppendLine($"Action: {response.Action}"); } - - var oppositeTransaction = new CryptoTransaction - { - WalletId = virtualWallet.Id, - DateTime = tx.DateTime, - TransactionType = oppositeType, - Symbol = tx.Symbol, - Quantity = oppositeQuantity, - Fee = 0m, - Comment = $"Virtuelles Gegenstück zu #{tx.Id} ({tx.WalletName})", - Address = tx.Address, - Network = tx.Network, - TransactionId = tx.TransactionId - }; - - dbContext.CryptoTransactions.Add(oppositeTransaction); - await dbContext.SaveChangesAsync(ct); - - var oppositeDto = new UnlinkedTransactionDTO - { - Id = oppositeTransaction.Id, - DateTime = oppositeTransaction.DateTime, - Type = oppositeType.ToString(), - Symbol = oppositeTransaction.Symbol, - Quantity = oppositeTransaction.Quantity, - QuantityAfterFee = oppositeTransaction.QuantityAfterFee, - Comment = oppositeTransaction.Comment, - Address = oppositeTransaction.Address, - WalletName = virtualWallet.Name, - TransactionId = oppositeTransaction.TransactionId, - Network = oppositeTransaction.Network - }; - - await LinkTransactionsAsync( - dbContext, - session, - tx, - oppositeDto, - $"Virtuelles Wallet: {virtualWallet.Name}", - ct); - - return null; - } - - private static async Task ResolveVirtualWalletAsync( - CryptoTrackerDbContext dbContext, - UserResponseDTO response, - CancellationToken ct) - { if (response.VirtualWalletId.HasValue) { - return await dbContext.Wallets - .FirstOrDefaultAsync(w => w.Id == response.VirtualWalletId.Value && w.IsVirtual, ct); - } - - var name = response.VirtualWalletName?.Trim(); - if (string.IsNullOrWhiteSpace(name)) - { - return null; + sb.AppendLine($"VirtualWalletId: {response.VirtualWalletId}"); } - - var existing = await dbContext.Wallets - .FirstOrDefaultAsync(w => w.Name == name, ct); - - if (existing != null) + if (!string.IsNullOrWhiteSpace(response.VirtualWalletName)) { - return existing.IsVirtual ? existing : null; + sb.AppendLine($"VirtualWalletName: {response.VirtualWalletName}"); } - - var wallet = new Wallet - { - Name = name, - IsVirtual = true - }; - - dbContext.Wallets.Add(wallet); - await dbContext.SaveChangesAsync(ct); - return wallet; + sb.AppendLine($"ShouldRemember: {response.ShouldRemember}"); + return sb.ToString(); } - private async Task GetTransactionDTOAsync( - CryptoTrackerDbContext dbContext, - int id, - CancellationToken ct) + private static void ResetQuestion(InteractiveLinkingSession session) { - return await dbContext.CryptoTransactions - .Include(t => t.Wallet) - .Where(t => t.Id == id) - .Select(t => new UnlinkedTransactionDTO - { - Id = t.Id, - DateTime = t.DateTime, - Type = t.TransactionType.ToString(), - Symbol = t.Symbol, - Quantity = t.Quantity, - QuantityAfterFee = t.QuantityAfterFee, - Comment = t.Comment, - Address = t.Address, - WalletName = t.Wallet.Name, - TransactionId = t.TransactionId, - Network = t.Network - }) - .FirstOrDefaultAsync(ct); - } - - private async Task> LoadLearnedRulesAsync( - CryptoTrackerDbContext dbContext, - CancellationToken ct) - { - // Für jetzt: Lade aus AgentMemory - var memory = await dbContext.AgentMemories - .Where(m => m.AgentKey == "transaction-linking" && m.Key.StartsWith("rule:")) - .ToListAsync(ct); - - return memory.Select(m => System.Text.Json.JsonSerializer.Deserialize(m.Value)) - .Where(r => r != null) - .Cast() - .ToList(); + session.CurrentQuestionId = null; + session.CurrentQuestion = null; + session.CurrentTransaction = null; + session.CurrentOptions = null; } - private async Task SaveLearnedRuleAsync( - CryptoTrackerDbContext dbContext, - LearnedRuleDTO rule, - CancellationToken ct) + private static Task GetRemainingUnlinkedCountAsync(CryptoTrackerDbContext dbContext, CancellationToken ct) { - var memory = new AgentMemory - { - AgentKey = "transaction-linking", - MemoryType = AgentMemoryType.UserDecision, - Key = $"rule:{rule.Id}", - Value = System.Text.Json.JsonSerializer.Serialize(rule), - Description = rule.Description, - CreatedAt = DateTimeOffset.UtcNow - }; - - dbContext.AgentMemories.Add(memory); - await dbContext.SaveChangesAsync(ct); + return dbContext.CryptoTransactions + .CountAsync(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked, ct); } - private async Task GetStatisticsAsync(CryptoTrackerDbContext dbContext, CancellationToken ct) + private static async Task GetStatisticsAsync(CryptoTrackerDbContext dbContext, CancellationToken ct) { var total = await dbContext.CryptoTransactions.CountAsync(ct); var linked = await dbContext.CryptoTransactions.CountAsync(t => t.OppositeTransactionId != null, ct); var external = await dbContext.CryptoTransactions.CountAsync(t => t.IsIntentionallyUnlinked, ct); - var unlinked = await dbContext.CryptoTransactions.CountAsync(t => + var unlinked = await dbContext.CryptoTransactions.CountAsync(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked, ct); return new LinkingStatisticsDTO @@ -876,15 +292,15 @@ private async Task GetStatisticsAsync(CryptoTrackerDbConte }; } - private async Task SendEventAsync(InteractiveLinkingSession session, LinkingEventDTO evt) + private Task SendEventAsync(InteractiveLinkingSession session, LinkingEventDTO evt) { - await _hubContext.Clients.Group(session.SessionId) + return _hubContext.Clients.Group(session.SessionId) .SendAsync("OnLinkingEvent", evt); } - private async Task SendSessionUpdateAsync(InteractiveLinkingSession session) + private Task SendSessionUpdateAsync(InteractiveLinkingSession session) { - await _hubContext.Clients.Group(session.SessionId) + return _hubContext.Clients.Group(session.SessionId) .SendAsync("OnSessionUpdate", session.ToDTO()); } } @@ -892,7 +308,7 @@ await _hubContext.Clients.Group(session.SessionId) /// /// Interne Session-Klasse /// -internal class InteractiveLinkingSession +public sealed class InteractiveLinkingSession { public string SessionId { get; init; } = ""; public DateTimeOffset StartedAt { get; init; } @@ -906,10 +322,10 @@ internal class InteractiveLinkingSession public string? CurrentQuestion { get; set; } public UnlinkedTransactionDTO? CurrentTransaction { get; set; } public IList? CurrentOptions { get; set; } - public IList? CurrentHints { get; set; } - public Dictionary? OptionToHintIndex { get; set; } + public bool PendingMemorySave { get; set; } public CancellationTokenSource CancellationSource { get; } = new(); - public required AsyncQueue UserResponseChannel { get; init; } + internal AsyncQueue UserResponseChannel { get; init; } = default!; + public List History { get; } = new(); public InteractiveLinkingSessionDTO ToDTO() => new() { @@ -927,25 +343,12 @@ internal class InteractiveLinkingSession }; } -/// -/// DTO für Fragen an den User -/// -internal record LinkingQuestionDTO -{ - public string QuestionId { get; init; } = ""; - public string Message { get; init; } = ""; - public IList Options { get; init; } = []; - public UnlinkedTransactionDTO? Transaction { get; init; } - public IList Hints { get; init; } = []; - public Dictionary OptionToHintIndex { get; init; } = []; -} - /// /// Einfache async Queue /// internal class AsyncQueue { - private readonly System.Threading.Channels.Channel _channel = + private readonly System.Threading.Channels.Channel _channel = System.Threading.Channels.Channel.CreateUnbounded(); public async Task EnqueueAsync(T item) diff --git a/src/CryptoTracker/Agent/Services/InteractiveLotLinkingService.cs b/src/CryptoTracker/Agent/Services/InteractiveLotLinkingService.cs index 8b5e9fc..9a9b033 100644 --- a/src/CryptoTracker/Agent/Services/InteractiveLotLinkingService.cs +++ b/src/CryptoTracker/Agent/Services/InteractiveLotLinkingService.cs @@ -1,4 +1,7 @@ using System.Collections.Concurrent; +using System.Text; +using CryptoTracker.Agent.Common; +using CryptoTracker.Agent.Definitions; using CryptoTracker.Entities; using CryptoTracker.Hubs; using CryptoTracker.Services; @@ -9,13 +12,14 @@ namespace CryptoTracker.Agent.Services; /// -/// Service für interaktives Lot-Linking mit Human-in-the-Loop +/// Service für interaktives Lot-Linking mit Human-in-the-Loop (agentisch) /// public class InteractiveLotLinkingService { private readonly IServiceScopeFactory _scopeFactory; private readonly IHubContext _hubContext; private readonly ILogger _logger; + private readonly ILotLinkingAgentContextAccessor _contextAccessor; // Aktive Sessions private readonly ConcurrentDictionary _sessions = new(); @@ -23,11 +27,13 @@ public class InteractiveLotLinkingService public InteractiveLotLinkingService( IServiceScopeFactory scopeFactory, IHubContext hubContext, - ILogger logger) + ILogger logger, + ILotLinkingAgentContextAccessor contextAccessor) { _scopeFactory = scopeFactory; _hubContext = hubContext; _logger = logger; + _contextAccessor = contextAccessor; } /// @@ -45,13 +51,6 @@ public async Task StartSessionAsync(Cancellatio _sessions[sessionId] = session; - // Lade initiale Statistiken - using var scope = _scopeFactory.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var pendingCount = await GetPendingAssignmentCountAsync(dbContext, ct); - session.TotalCount = pendingCount; - // Starte den Linking-Prozess im Hintergrund _ = Task.Run(() => RunLotLinkingProcessAsync(session, ct), ct); @@ -105,10 +104,10 @@ public async Task DeleteLearnedRuleAsync(string ruleId, CancellationToken { using var scope = _scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - + var memory = await dbContext.AgentMemories .FirstOrDefaultAsync(m => m.AgentKey == "lot-linking" && m.Key == $"rule:{ruleId}", ct); - + if (memory != null) { dbContext.AgentMemories.Remove(memory); @@ -119,7 +118,7 @@ public async Task DeleteLearnedRuleAsync(string ruleId, CancellationToken } /// - /// Hauptprozess für interaktives Lot-Linking + /// Hauptprozess für interaktives Lot-Linking (agentisch, iterativ) /// private async Task RunLotLinkingProcessAsync(InteractiveLotLinkingSession session, CancellationToken ct) { @@ -129,60 +128,103 @@ private async Task RunLotLinkingProcessAsync(InteractiveLotLinkingSession sessio { using var scope = _scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var lotService = scope.ServiceProvider.GetRequiredService(); - var coinRateService = scope.ServiceProvider.GetRequiredService(); + var agentBuilder = scope.ServiceProvider.GetRequiredService(); - // 1. Lade alle ausstehenden Zuordnungen - var pendingAssignments = await LoadPendingAssignmentsAsync(dbContext, linkedCt.Token); - session.TotalCount = pendingAssignments.Count; + if (!agentBuilder.IsConfigured()) + { + await SendEventAsync(session, new LotLinkingEventDTO + { + EventType = "error", + Message = "AI-Service nicht konfiguriert. Bitte OpenAI-Einstellungen prüfen.", + ProcessedCount = 0, + TotalCount = 0 + }); + return; + } + session.TotalCount = await GetPendingAssignmentCountAsync(dbContext, linkedCt.Token); await SendSessionUpdateAsync(session); - // 2. Lade gelernte Regeln - var activeRules = (await LoadLearnedRulesAsync(dbContext, linkedCt.Token)).ToList(); + var agent = agentBuilder.BuildAgent(LotLinkingAgentDefinition.KEY); - // 3. Verarbeite jede ausstehende Zuordnung - foreach (var assignment in pendingAssignments) + session.History.Add(new ChatMessageDTO { - if (linkedCt.Token.IsCancellationRequested) - break; + Role = "user", + Content = "Starte eine neue Lot-Linking-Session. " + + "Lade gespeicherte Regeln und alle ausstehenden Zuordnungen. " + + "Erstelle Root-Lots für Fiat-Käufe und ordne Transfers/Swaps/Verkäufe zu. " + + "Nutze FIFO als Default und frage nur, wenn FIFO nicht möglich ist." + }); - session.CurrentAssignment = assignment; - session.ProcessedCount++; + var lastRemaining = session.TotalCount; + var idleIterations = 0; - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "progress", - Message = $"Verarbeite {assignment.Symbol} {assignment.Quantity:F8} ({assignment.WalletName})", - Assignment = assignment, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); + while (!linkedCt.Token.IsCancellationRequested) + { + var allowMemorySave = session.PendingMemorySave; + session.PendingMemorySave = false; + + var prompt = BuildAgentInput(session, allowMemorySave); - // Prüfe ob gelernte Regel zutrifft - var matchingRule = FindMatchingRule(assignment, activeRules); - if (matchingRule != null) + using (_contextAccessor.Use(new LotLinkingAgentContext( + session, + evt => SendEventAsync(session, evt), + allowMemorySave, + allowQuestions: true))) { - var newRule = await ApplyRuleAsync(dbContext, lotService, coinRateService, session, assignment, matchingRule, linkedCt.Token); - if (newRule != null) + var result = await agent.RunAsync(prompt, cancellationToken: linkedCt.Token); + if (!string.IsNullOrWhiteSpace(result.Text)) { - activeRules.Add(newRule); + session.History.Add(new ChatMessageDTO { Role = "assistant", Content = result.Text ?? "" }); } + } + + if (session.CurrentQuestionId != null) + { + var response = await session.UserResponseChannel.DequeueAsync(linkedCt.Token); + session.PendingMemorySave = response.ShouldRemember; + session.History.Add(new ChatMessageDTO { Role = "user", Content = FormatUserResponse(response) }); + ResetQuestion(session); continue; } - // Entscheide basierend auf Typ - if (assignment.Type == "Transaction" && assignment.Direction == "Receive") + var remaining = await GetPendingAssignmentCountAsync(dbContext, linkedCt.Token); + session.ProcessedCount = Math.Max(0, session.TotalCount - remaining); + await SendSessionUpdateAsync(session); + + if (remaining == 0) { - await HandleReceiveTransactionAsync(dbContext, lotService, coinRateService, session, assignment, activeRules, linkedCt.Token); + break; + } + + if (remaining == lastRemaining) + { + idleIterations++; + if (idleIterations >= 2) + { + await SendEventAsync(session, new LotLinkingEventDTO + { + EventType = "info", + Message = "Keine weiteren sicheren Zuordnungen gefunden. Bitte manuell prüfen.", + ProcessedCount = session.ProcessedCount, + TotalCount = session.TotalCount + }); + break; + } } - else if (assignment.Type == "Trade" && assignment.Direction == "Sell") + else { - await HandleSellTradeAsync(dbContext, lotService, session, assignment, activeRules, linkedCt.Token); + idleIterations = 0; + lastRemaining = remaining; } + + session.History.Add(new ChatMessageDTO + { + Role = "user", + Content = "Bitte fahre mit den verbleibenden Zuordnungen fort." + }); } - // 4. Session abschließen session.IsActive = false; var finalStats = await GetStatisticsAsync(dbContext, linkedCt.Token); @@ -205,741 +247,76 @@ await _hubContext.Clients.Group(session.SessionId) } } - /// - /// Behandelt eine Receive-Transaktion ohne verknüpfte Send-Transaktion - /// - private async Task HandleReceiveTransactionAsync( - CryptoTrackerDbContext dbContext, - LotService lotService, - CoinRateService coinRateService, - InteractiveLotLinkingSession session, - PendingLotAssignmentDTO assignment, - List activeRules, - CancellationToken ct) + private static string BuildAgentInput(InteractiveLotLinkingSession session, bool allowMemorySave) { - // Lade Transaktion für Details - var transaction = await dbContext.CryptoTransactions - .Include(t => t.Wallet) - .FirstOrDefaultAsync(t => t.Id == assignment.Id, ct); - - if (transaction == null) return; + var sb = new StringBuilder(); - // Prüfe ob es eine verknüpfte Send-Transaktion gibt (interner Transfer) - if (transaction.OppositeTransactionId.HasValue) + sb.AppendLine("SESSION-HISTORIE (gekürzt):"); + foreach (var msg in session.History.TakeLast(20)) { - // Interner Transfer - Lots sollten vom Send-Wallet übertragen werden - await HandleInternalTransferAsync(dbContext, lotService, session, transaction, ct); - return; + sb.AppendLine($"{msg.Role.ToUpperInvariant()}: {msg.Content}"); } - // Prüfe ob Kommentar eindeutige Keywords enthält (Auto-Erkennung) - var autoDetectedType = DetectAcquisitionTypeFromComment(transaction.Comment); - if (autoDetectedType.HasValue) - { - _logger.LogInformation("Auto-erkannt aus Kommentar '{Comment}': {Type}", - transaction.Comment, autoDetectedType.Value); - - await CreateLotFromAutoDetectionAsync(dbContext, lotService, coinRateService, session, - assignment, transaction, autoDetectedType.Value, ct); - return; - } - - // Externes Receive - frage User nach Herkunft - var question = BuildExternalReceiveQuestion(assignment, transaction.Comment); - await AskUserAsync(session, question, ct); - - // Warte auf Antwort - var response = await session.UserResponseChannel.DequeueAsync(ct); - var newRule = await ProcessReceiveResponseAsync(dbContext, lotService, coinRateService, session, assignment, transaction, response, activeRules, ct); - - if (newRule != null) - { - activeRules.Add(newRule); - } - } - - /// - /// Erkennt den Acquisition-Type automatisch aus dem Kommentar anhand von Keywords - /// - private static LotAcquisitionType? DetectAcquisitionTypeFromComment(string? comment) - { - if (string.IsNullOrWhiteSpace(comment)) - return null; - - var lowerComment = comment.ToLowerInvariant(); - - // Staking Keywords (häufigste zuerst) - if (lowerComment.Contains("staking") || - lowerComment.Contains("stake reward") || - lowerComment.Contains("pos reward") || - lowerComment.Contains("delegation reward") || - lowerComment.Contains("validator reward")) - { - return LotAcquisitionType.Staking; - } - - // Airdrop Keywords - if (lowerComment.Contains("airdrop") || - lowerComment.Contains("air drop") || - lowerComment.Contains("空投")) // Chinesisch für Airdrop - { - return LotAcquisitionType.Airdrop; - } - - // Mining Keywords - if (lowerComment.Contains("mining") || - lowerComment.Contains("miner reward") || - lowerComment.Contains("block reward") || - lowerComment.Contains("pow reward")) - { - return LotAcquisitionType.Mining; - } - - return null; - } - - /// - /// Erstellt ein Lot basierend auf Auto-Erkennung aus dem Kommentar - /// - private async Task CreateLotFromAutoDetectionAsync( - CryptoTrackerDbContext dbContext, - LotService lotService, - CoinRateService coinRateService, - InteractiveLotLinkingSession session, - PendingLotAssignmentDTO assignment, - CryptoTransaction transaction, - LotAcquisitionType acquisitionType, - CancellationToken ct) - { - // Hole EUR-Preis zum Zeitpunkt - var (rate, _) = await coinRateService.GetPreviousCloseRateWithSourceAsync( - assignment.Symbol, - transaction.DateTime.UtcDateTime); - var acquisitionPriceEur = rate ?? 0; - - // Erstelle Lot - var lotRequest = new ManualLotRequest - { - Symbol = assignment.Symbol, - WalletId = assignment.WalletId, - Quantity = assignment.Quantity, - AcquisitionDate = transaction.DateTime, - AcquisitionPriceEur = acquisitionPriceEur, - AcquisitionType = acquisitionType, - SourceTransactionId = transaction.Id, - Note = $"Auto-erkannt aus Kommentar: {acquisitionType}" - }; - - var lot = await lotService.CreateManualLotAsync(lotRequest); - session.CreatedLotsCount++; - - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "lot_created", - Message = $"Lot erstellt (Auto): {assignment.Symbol} {assignment.Quantity:F8} @ {acquisitionPriceEur:F2}€ ({acquisitionType}) - Kommentar enthielt '{acquisitionType}'", - Assignment = assignment, - CreatedLot = ToLotDTO(lot), - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); + sb.AppendLine(); + sb.AppendLine("STATUS:"); + sb.AppendLine($"- Total pending: {session.TotalCount}"); + sb.AppendLine($"- Assigned: {session.AssignedCount}"); + sb.AppendLine($"- Created lots: {session.CreatedLotsCount}"); + sb.AppendLine($"- Skipped: {session.SkippedCount}"); + sb.AppendLine($"- Memory save allowed: {allowMemorySave}"); + sb.AppendLine(); + sb.AppendLine("ANWEISUNG:"); + sb.AppendLine("Nutze FIFO als Default. Frage nur, wenn FIFO nicht möglich ist."); + + return sb.ToString(); } - /// - /// Behandelt einen internen Transfer (Send → Receive verknüpft) - /// - private async Task HandleInternalTransferAsync( - CryptoTrackerDbContext dbContext, - LotService lotService, - InteractiveLotLinkingSession session, - CryptoTransaction receiveTransaction, - CancellationToken ct) + private static string FormatUserResponse(LotLinkingUserResponseDTO response) { - var sendTransaction = await dbContext.CryptoTransactions - .Include(t => t.Wallet) - .FirstOrDefaultAsync(t => t.Id == receiveTransaction.OppositeTransactionId, ct); - - if (sendTransaction == null) + var sb = new StringBuilder(); + sb.AppendLine("USER_RESPONSE"); + sb.AppendLine($"QuestionId: {response.QuestionId}"); + sb.AppendLine($"Response: {response.Response}"); + if (!string.IsNullOrWhiteSpace(response.CustomText)) { - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "error", - Message = $"Send-Transaktion #{receiveTransaction.OppositeTransactionId} nicht gefunden" - }); - return; + sb.AppendLine($"CustomText: {response.CustomText}"); } - - // Hole verfügbare Lots vom Send-Wallet - var availableLots = await lotService.GetAvailableLotsAsync( - sendTransaction.WalletId, - sendTransaction.Symbol); - - if (availableLots.Count == 0) + if (response.LotAllocations != null && response.LotAllocations.Count > 0) { - // Keine Lots verfügbar - frage User - var question = new LotLinkingQuestionDTO + sb.AppendLine("LotAllocations:"); + foreach (var allocation in response.LotAllocations) { - QuestionId = Guid.NewGuid().ToString("N"), - Message = $"Keine Lots auf {sendTransaction.Wallet.Name} für Transfer von {sendTransaction.Quantity:F8} {sendTransaction.Symbol} verfügbar.", - Options = ["Überspringen", "Abbrechen"], - Assignment = ToPendingDTO(receiveTransaction) - }; - - await AskUserAsync(session, question, ct); - var resp = await session.UserResponseChannel.DequeueAsync(ct); - - if (resp.Response.Contains("Abbrechen", StringComparison.OrdinalIgnoreCase)) - { - session.CancellationSource.Cancel(); - } - else - { - session.SkippedCount++; + sb.AppendLine($"- LotId: {allocation.LotId}, Quantity: {allocation.Quantity}"); } - return; - } - - // FIFO-Vorschlag erstellen - var fifoAllocations = await lotService.SuggestFifoAllocationAsync( - sendTransaction.WalletId, - sendTransaction.Symbol, - sendTransaction.QuantityAfterFee); - - if (fifoAllocations.Sum(a => a.Quantity) >= sendTransaction.QuantityAfterFee * 0.999m) // 0.1% Toleranz - { - // Automatisch zuordnen - var allocations = fifoAllocations.Select(a => new LotAllocation - { - LotId = a.LotId, - Quantity = a.Quantity - }).ToList(); - - var resultingLots = await lotService.TransferLotsAsync( - sendTransaction.Id, - receiveTransaction.Id, - allocations); - - session.AssignedCount++; - - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "assigned", - Message = $"Transfer: {sendTransaction.Symbol} {sendTransaction.QuantityAfterFee:F8} ({sendTransaction.Wallet.Name} → {receiveTransaction.Wallet.Name})", - Assignment = ToPendingDTO(receiveTransaction), - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - } - else - { - // Nicht genug Lots - frage User - session.SkippedCount++; - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "skipped", - Message = $"Nicht genug Lots für Transfer: {sendTransaction.Symbol} (benötigt: {sendTransaction.QuantityAfterFee:F8}, verfügbar: {fifoAllocations.Sum(a => a.Quantity):F8})", - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); } + sb.AppendLine($"ShouldRemember: {response.ShouldRemember}"); + return sb.ToString(); } - /// - /// Behandelt einen Verkauf (Trade mit Typ Sell) - /// - private async Task HandleSellTradeAsync( - CryptoTrackerDbContext dbContext, - LotService lotService, - InteractiveLotLinkingSession session, - PendingLotAssignmentDTO assignment, - List activeRules, - CancellationToken ct) + private static void ResetQuestion(InteractiveLotLinkingSession session) { - var trade = await dbContext.CryptoTrades - .Include(t => t.Wallet) - .FirstOrDefaultAsync(t => t.Id == assignment.Id, ct); - - if (trade == null) return; - - // Hole verfügbare Lots - var availableLots = await lotService.GetAvailableLotsAsync(trade.WalletId, trade.Symbol); - - if (availableLots.Count == 0) - { - session.SkippedCount++; - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "skipped", - Message = $"Keine Lots für Verkauf: {trade.Symbol} {trade.Quantity:F8}", - Assignment = assignment, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - return; - } - - // FIFO-Vorschlag - var fifoAllocations = await lotService.SuggestFifoAllocationAsync( - trade.WalletId, - trade.Symbol, - trade.Quantity, - prioritizeAltbestand: true); // Altbestand zuerst (steuerfrei) - - if (fifoAllocations.Sum(a => a.Quantity) >= trade.Quantity * 0.999m) - { - // Automatisch zuordnen mit FIFO - var allocations = fifoAllocations.Select(a => new LotAllocation - { - LotId = a.LotId, - Quantity = a.Quantity - }).ToList(); - - // Berechne Verkaufspreis in EUR - var salePriceEur = trade.Price; // Annahme: bereits in EUR oder konvertiert - if (!trade.OppositeSymbol.Equals("EUR", StringComparison.OrdinalIgnoreCase)) - { - // Müsste konvertiert werden - für jetzt überspringen - session.SkippedCount++; - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "skipped", - Message = $"Verkauf in {trade.OppositeSymbol} - manuelle Zuordnung erforderlich", - Assignment = assignment - }); - return; - } - - var result = await lotService.SellLotsAsync(trade.Id, allocations, salePriceEur); - - session.AssignedCount++; - - var taxInfo = result.TaxFreeQuantity > 0 - ? $" (Steuerfrei: {result.TaxFreeGain:F2}€, Steuerpflichtig: {result.TaxableGain:F2}€)" - : $" (Gewinn: {result.TotalRealizedGain:F2}€)"; - - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "assigned", - Message = $"Verkauf: {trade.Symbol} {trade.Quantity:F8} für {trade.Price * trade.Quantity:F2}€{taxInfo}", - Assignment = assignment, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - } - else - { - // Nicht genug Lots - session.SkippedCount++; - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "skipped", - Message = $"Nicht genug Lots für Verkauf: {trade.Symbol} (benötigt: {trade.Quantity:F8})", - Assignment = assignment, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - } + session.CurrentQuestionId = null; + session.CurrentQuestion = null; + session.CurrentAssignment = null; + session.CurrentOptions = null; + session.CurrentLotOptions = null; } - /// - /// Baut Frage für externes Receive - /// - private LotLinkingQuestionDTO BuildExternalReceiveQuestion(PendingLotAssignmentDTO assignment, string? comment) + private static async Task GetPendingAssignmentCountAsync(CryptoTrackerDbContext dbContext, CancellationToken ct) { - var options = new List - { - "Externer Eingang (Kauf woanders)", - "Airdrop", - "Staking Reward", - "Mining Reward", - "Schenkung / Erbe", - "Überspringen" - }; - - var message = $"**Externes Receive:** {assignment.Symbol} {assignment.Quantity:F8}\n" + - $"**Wallet:** {assignment.WalletName}\n" + - $"**Datum:** {assignment.DateTime:dd.MM.yyyy HH:mm}"; - - if (!string.IsNullOrEmpty(comment)) - { - message += $"\n**Kommentar:** \"{comment}\""; - } - - message += "\n\n**Woher stammen diese Coins?**"; - - return new LotLinkingQuestionDTO - { - QuestionId = Guid.NewGuid().ToString("N"), - Message = message, - Options = options, - Assignment = assignment, - RequiresPrice = true - }; - } - - /// - /// Verarbeitet die User-Antwort für externes Receive - /// - private async Task ProcessReceiveResponseAsync( - CryptoTrackerDbContext dbContext, - LotService lotService, - CoinRateService coinRateService, - InteractiveLotLinkingSession session, - PendingLotAssignmentDTO assignment, - CryptoTransaction transaction, - LotLinkingUserResponseDTO response, - List activeRules, - CancellationToken ct) - { - var responseText = response.Response.ToLowerInvariant(); - LotLinkingRuleDTO? newRule = null; - - if (responseText.Contains("überspringen") || responseText.Contains("skip")) - { - session.SkippedCount++; - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "skipped", - Message = $"Übersprungen: {assignment.Symbol} {assignment.Quantity:F8}", - Assignment = assignment, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - return null; - } - - // Bestimme Acquisition Type - var acquisitionType = responseText switch - { - var r when r.Contains("airdrop") => LotAcquisitionType.Airdrop, - var r when r.Contains("staking") => LotAcquisitionType.Staking, - var r when r.Contains("mining") => LotAcquisitionType.Mining, - var r when r.Contains("schenkung") || r.Contains("erbe") => LotAcquisitionType.Gift, - _ => LotAcquisitionType.ExternalDeposit - }; - - // Hole EUR-Preis zum Zeitpunkt - decimal acquisitionPriceEur; - if (response.AcquisitionPriceEur.HasValue && response.AcquisitionPriceEur.Value > 0) - { - acquisitionPriceEur = response.AcquisitionPriceEur.Value; - } - else - { - // Versuche Marktpreis zu holen - var (rate, _) = await coinRateService.GetPreviousCloseRateWithSourceAsync( - assignment.Symbol, - transaction.DateTime.UtcDateTime); - acquisitionPriceEur = rate ?? 0; - } - - // Erstelle Lot - var lotRequest = new ManualLotRequest - { - Symbol = assignment.Symbol, - WalletId = assignment.WalletId, - Quantity = assignment.Quantity, - AcquisitionDate = transaction.DateTime, - AcquisitionPriceEur = acquisitionPriceEur, - AcquisitionType = acquisitionType, - SourceTransactionId = transaction.Id, - Note = response.Note ?? $"Erstellt via Lot-Linking Wizard ({acquisitionType})" - }; - - var lot = await lotService.CreateManualLotAsync(lotRequest); - session.CreatedLotsCount++; - - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "lot_created", - Message = $"Lot erstellt: {assignment.Symbol} {assignment.Quantity:F8} @ {acquisitionPriceEur:F2}€ ({acquisitionType})", - Assignment = assignment, - CreatedLot = ToLotDTO(lot), - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - - // Regel lernen wenn gewünscht - if (response.ShouldRemember && !string.IsNullOrEmpty(transaction.Comment)) - { - var action = acquisitionType switch - { - LotAcquisitionType.Airdrop => "create_lot_airdrop", - LotAcquisitionType.Staking => "create_lot_staking", - LotAcquisitionType.Mining => "create_lot_mining", - _ => "create_lot_external" - }; - - // Extrahiere ein sinnvolles Pattern aus dem Kommentar - var pattern = ExtractMeaningfulPattern(transaction.Comment); - - newRule = new LotLinkingRuleDTO - { - Id = Guid.NewGuid().ToString("N"), - RuleType = "comment_pattern", - Pattern = pattern, - Action = action, - Description = $"Kommentar enthält '{pattern}' → {acquisitionType}", - CreatedAt = DateTimeOffset.UtcNow, - TimesApplied = 0 - }; - - await SaveLearnedRuleAsync(dbContext, newRule, ct); - - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "rule_learned", - Message = $"Regel gelernt: Kommentar enthält '{pattern}' → {acquisitionType}" - }); - } - - return newRule; - } - - /// - /// Findet passende Regel für comment_pattern, symbol_pattern und wallet_pattern - /// - private LotLinkingRuleDTO? FindMatchingRule(PendingLotAssignmentDTO assignment, IList rules) - { - foreach (var rule in rules) - { - var matches = rule.RuleType switch - { - "comment_pattern" => !string.IsNullOrEmpty(assignment.Comment) && - assignment.Comment.Contains(rule.Pattern, StringComparison.OrdinalIgnoreCase), - "symbol_pattern" => assignment.Symbol.Equals(rule.Pattern, StringComparison.OrdinalIgnoreCase), - "wallet_pattern" => assignment.WalletName.Contains(rule.Pattern, StringComparison.OrdinalIgnoreCase), - _ => false - }; - - if (matches) - { - _logger.LogInformation("Regel gefunden: {RuleType} '{Pattern}' → {Action} für {Symbol}", - rule.RuleType, rule.Pattern, rule.Action, assignment.Symbol); - return rule; - } - } - return null; - } - - /// - /// Wendet eine Regel an - /// - private async Task ApplyRuleAsync( - CryptoTrackerDbContext dbContext, - LotService lotService, - CoinRateService coinRateService, - InteractiveLotLinkingSession session, - PendingLotAssignmentDTO assignment, - LotLinkingRuleDTO rule, - CancellationToken ct) - { - _logger.LogInformation("Wende Regel an: {Pattern} → {Action}", rule.Pattern, rule.Action); - - if (rule.Action.StartsWith("create_lot_")) - { - var transaction = await dbContext.CryptoTransactions.FindAsync([assignment.Id], ct); - if (transaction == null) return null; - - var acquisitionType = rule.Action switch - { - "create_lot_airdrop" => LotAcquisitionType.Airdrop, - "create_lot_staking" => LotAcquisitionType.Staking, - "create_lot_mining" => LotAcquisitionType.Mining, - _ => LotAcquisitionType.ExternalDeposit - }; - - var (rate, _) = await coinRateService.GetPreviousCloseRateWithSourceAsync( - assignment.Symbol, - transaction.DateTime.UtcDateTime); - - var lotRequest = new ManualLotRequest - { - Symbol = assignment.Symbol, - WalletId = assignment.WalletId, - Quantity = assignment.Quantity, - AcquisitionDate = transaction.DateTime, - AcquisitionPriceEur = rate ?? 0, - AcquisitionType = acquisitionType, - SourceTransactionId = transaction.Id, - Note = $"Auto-erstellt via Regel: {rule.Description}" - }; - - var lot = await lotService.CreateManualLotAsync(lotRequest); - session.CreatedLotsCount++; - - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "lot_created", - Message = $"Lot erstellt (Regel): {assignment.Symbol} {assignment.Quantity:F8} ({acquisitionType})", - Assignment = assignment, - CreatedLot = ToLotDTO(lot), - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - - // Update usage count - await UpdateRuleUsageAsync(dbContext, rule.Id, ct); - } - else if (rule.Action == "skip") - { - session.SkippedCount++; - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "skipped", - Message = $"Übersprungen (Regel): {assignment.Symbol} {assignment.Quantity:F8}", - Assignment = assignment, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - } - - return null; - } - - private async Task AskUserAsync(InteractiveLotLinkingSession session, LotLinkingQuestionDTO question, CancellationToken ct) - { - session.CurrentQuestionId = question.QuestionId; - session.CurrentQuestion = question.Message; - session.CurrentOptions = question.Options; - - await SendEventAsync(session, new LotLinkingEventDTO - { - EventType = "question", - Message = question.Message, - Assignment = question.Assignment, - QuestionId = question.QuestionId, - Options = question.Options, - LotOptions = question.LotOptions, - ProcessedCount = session.ProcessedCount, - TotalCount = session.TotalCount - }); - } - - /// - /// Extrahiert ein sinnvolles Pattern aus einem Kommentar. - /// Entfernt dynamische Teile wie IDs, Hashes, Timestamps. - /// - private static string ExtractMeaningfulPattern(string comment) - { - // Bekannte Keywords die wir als Pattern verwenden können - var keywords = new[] - { - "staking", "stake", "delegation", "validator", "pos reward", - "airdrop", "air drop", "空投", - "mining", "miner", "block reward", "pow reward", - "distribution", "reward", "bonus", "referral", - "transfer", "deposit", "withdrawal" - }; - - var lowerComment = comment.ToLowerInvariant(); - - // Suche nach bekannten Keywords - foreach (var keyword in keywords) - { - if (lowerComment.Contains(keyword)) - { - return keyword; - } - } - - // Fallback: Entferne dynamische Teile (IDs, Hashes, Zahlen am Ende) - // z.B. "Distribution - staking project send. detailId-92954850" → "Distribution - staking project send" - var pattern = comment; - - // Entferne detailId-XXXXX, id-XXXXX, hash-XXXXX etc. - pattern = System.Text.RegularExpressions.Regex.Replace( - pattern, - @"\s*(detailId|id|hash|txid|ref|reference)[-_]?\d+[a-fA-F0-9]*", - "", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - - // Entferne lange Hex-Strings (Transaction Hashes) - pattern = System.Text.RegularExpressions.Regex.Replace( - pattern, - @"[a-fA-F0-9]{20,}", - ""); - - // Entferne Timestamps und Datumsangaben - pattern = System.Text.RegularExpressions.Regex.Replace( - pattern, - @"\d{4}[-/]\d{2}[-/]\d{2}|\d{2}[-/]\d{2}[-/]\d{4}|\d{10,}", - ""); - - // Bereinige Leerzeichen und Satzzeichen am Ende - pattern = pattern.Trim().TrimEnd('.', ',', '-', '_', ' '); - - // Wenn das Pattern zu kurz wird, nimm die ersten sinnvollen Wörter - if (pattern.Length < 5) - { - var words = comment.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); - pattern = string.Join(" ", words.Take(3)); - } - - return pattern; - } - - #region Helper Methods - - private async Task GetPendingAssignmentCountAsync(CryptoTrackerDbContext dbContext, CancellationToken ct) - { - var receiveCount = await dbContext.CryptoTransactions - .CountAsync(t => t.TransactionType == TransactionType.Receive - && !t.LotAssignmentConfirmed, ct); - - var sellCount = await dbContext.CryptoTrades - .CountAsync(t => t.TradeType == TradeType.Sell - && !t.LotAssignmentConfirmed, ct); - - return receiveCount + sellCount; - } - - private async Task> LoadPendingAssignmentsAsync( - CryptoTrackerDbContext dbContext, - CancellationToken ct) - { - var transactions = await dbContext.CryptoTransactions - .Include(t => t.Wallet) - .Include(t => t.OppositeWallet) - .Where(t => t.TransactionType == TransactionType.Receive - && !t.LotAssignmentConfirmed) - .OrderBy(t => t.DateTime) - .Select(t => new PendingLotAssignmentDTO( - "Transaction", - t.Id, - t.DateTime, - t.Symbol, - t.Quantity, - t.Wallet.Name, - t.WalletId, - "Receive", - t.OppositeWallet != null ? t.OppositeWallet.Name : null, - t.Comment)) - .ToListAsync(ct); - - var trades = await dbContext.CryptoTrades - .Include(t => t.Wallet) - .Where(t => t.TradeType == TradeType.Sell - && !t.LotAssignmentConfirmed) - .OrderBy(t => t.DateTime) - .Select(t => new PendingLotAssignmentDTO( - "Trade", - t.Id, - t.DateTime, - t.Symbol, - t.Quantity, - t.Wallet.Name, - t.WalletId, - "Sell", - null, - t.Comment)) - .ToListAsync(ct); + var pendingReceive = await dbContext.CryptoTransactions + .CountAsync(t => t.TransactionType == TransactionType.Receive && !t.LotAssignmentConfirmed, ct); + var pendingSell = await dbContext.CryptoTrades + .CountAsync(t => t.TradeType == TradeType.Sell && !t.LotAssignmentConfirmed, ct); + var pendingBuy = await dbContext.CryptoTrades + .CountAsync(t => t.TradeType == TradeType.Buy + && t.ResultingLotId == null + && FiatSymbols.ForQuery.Contains(t.OppositeSymbol), ct); - return transactions.Concat(trades) - .OrderBy(a => a.DateTime) - .ToList(); + return pendingReceive + pendingSell + pendingBuy; } - private async Task> LoadLearnedRulesAsync( + private static async Task> LoadLearnedRulesAsync( CryptoTrackerDbContext dbContext, CancellationToken ct) { @@ -954,46 +331,7 @@ private async Task> LoadLearnedRulesAsync( .ToList(); } - private async Task SaveLearnedRuleAsync( - CryptoTrackerDbContext dbContext, - LotLinkingRuleDTO rule, - CancellationToken ct) - { - var memory = new AgentMemory - { - AgentKey = "lot-linking", - MemoryType = AgentMemoryType.UserDecision, - Key = $"rule:{rule.Id}", - Value = System.Text.Json.JsonSerializer.Serialize(rule), - Description = rule.Description, - CreatedAt = DateTimeOffset.UtcNow - }; - - dbContext.AgentMemories.Add(memory); - await dbContext.SaveChangesAsync(ct); - } - - private async Task UpdateRuleUsageAsync( - CryptoTrackerDbContext dbContext, - string ruleId, - CancellationToken ct) - { - var memory = await dbContext.AgentMemories - .FirstOrDefaultAsync(m => m.AgentKey == "lot-linking" && m.Key == $"rule:{ruleId}", ct); - - if (memory != null) - { - var rule = System.Text.Json.JsonSerializer.Deserialize(memory.Value); - if (rule != null) - { - var updated = rule with { TimesApplied = rule.TimesApplied + 1 }; - memory.Value = System.Text.Json.JsonSerializer.Serialize(updated); - await dbContext.SaveChangesAsync(ct); - } - } - } - - private async Task GetStatisticsAsync( + private static async Task GetStatisticsAsync( CryptoTrackerDbContext dbContext, CancellationToken ct) { @@ -1001,71 +339,42 @@ private async Task GetStatisticsAsync( .CountAsync(t => t.TransactionType == TransactionType.Receive && !t.LotAssignmentConfirmed, ct); var pendingSell = await dbContext.CryptoTrades .CountAsync(t => t.TradeType == TradeType.Sell && !t.LotAssignmentConfirmed, ct); + var pendingBuy = await dbContext.CryptoTrades + .CountAsync(t => t.TradeType == TradeType.Buy + && t.ResultingLotId == null + && FiatSymbols.ForQuery.Contains(t.OppositeSymbol), ct); var completedTx = await dbContext.CryptoTransactions .CountAsync(t => t.LotAssignmentConfirmed, ct); var totalLots = await dbContext.AssetLots.CountAsync(ct); return new LotLinkingStatisticsDTO { - TotalPendingAssignments = pendingReceive + pendingSell, + TotalPendingAssignments = pendingReceive + pendingSell + pendingBuy, PendingReceiveTransactions = pendingReceive, PendingSellTrades = pendingSell, + PendingBuyTrades = pendingBuy, CompletedAssignments = completedTx, LotsCreated = totalLots }; } - private async Task SendEventAsync(InteractiveLotLinkingSession session, LotLinkingEventDTO evt) + private Task SendEventAsync(InteractiveLotLinkingSession session, LotLinkingEventDTO evt) { - await _hubContext.Clients.Group(session.SessionId) + return _hubContext.Clients.Group(session.SessionId) .SendAsync("OnLotLinkingEvent", evt); } - private async Task SendSessionUpdateAsync(InteractiveLotLinkingSession session) + private Task SendSessionUpdateAsync(InteractiveLotLinkingSession session) { - await _hubContext.Clients.Group(session.SessionId) + return _hubContext.Clients.Group(session.SessionId) .SendAsync("OnLotLinkingSessionUpdate", session.ToDTO()); } - - private static PendingLotAssignmentDTO ToPendingDTO(CryptoTransaction t) => new( - "Transaction", - t.Id, - t.DateTime, - t.Symbol, - t.Quantity, - t.Wallet?.Name ?? "", - t.WalletId, - t.TransactionType.ToString(), - t.OppositeWallet?.Name, - t.Comment); - - private static LotDTO ToLotDTO(AssetLot lot) => new( - lot.Id, - lot.Symbol, - lot.CurrentWalletId, - lot.CurrentWallet?.Name ?? "", - lot.RemainingQuantity, - lot.OriginalQuantity, - lot.AcquisitionDate, - lot.AcquisitionPriceEur, - lot.TotalAcquisitionCostEur, - lot.AcquisitionType.ToString(), - lot.IsAltbestand, - lot.IsFullyConsumed, - lot.Note, - lot.ParentLotId, - lot.SourceTradeId, - lot.SourceTransactionId, - lot.IsFlowComplete, - lot.FlowIncompleteReason); - - #endregion } /// -/// Interne Session-Klasse für Lot-Linking +/// Interne Session-Klasse /// -internal class InteractiveLotLinkingSession +public sealed class InteractiveLotLinkingSession { public string SessionId { get; init; } = ""; public DateTimeOffset StartedAt { get; init; } @@ -1079,8 +388,11 @@ internal class InteractiveLotLinkingSession public string? CurrentQuestion { get; set; } public PendingLotAssignmentDTO? CurrentAssignment { get; set; } public IList? CurrentOptions { get; set; } + public IList? CurrentLotOptions { get; set; } + public bool PendingMemorySave { get; set; } public CancellationTokenSource CancellationSource { get; } = new(); - public required AsyncQueue UserResponseChannel { get; init; } + internal AsyncQueue UserResponseChannel { get; init; } = default!; + public List History { get; } = new(); public InteractiveLotLinkingSessionDTO ToDTO() => new() { @@ -1094,19 +406,7 @@ internal class InteractiveLotLinkingSession CurrentQuestionId = CurrentQuestionId, CurrentQuestion = CurrentQuestion, CurrentAssignment = CurrentAssignment, - CurrentOptions = CurrentOptions + CurrentOptions = CurrentOptions, + CurrentLotOptions = CurrentLotOptions }; } - -/// -/// DTO für Fragen an den User -/// -internal record LotLinkingQuestionDTO -{ - public string QuestionId { get; init; } = ""; - public string Message { get; init; } = ""; - public IList Options { get; init; } = []; - public IList? LotOptions { get; init; } - public PendingLotAssignmentDTO? Assignment { get; init; } - public bool RequiresPrice { get; init; } -} diff --git a/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs b/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs index 850d7be..18ffca3 100644 --- a/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs +++ b/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs @@ -110,167 +110,40 @@ public async Task ContinueSessionAsync( } /// - /// Führt automatische Verknüpfung durch - OHNE AI-Aufrufe! - /// Verwendet regelbasierte Logik und vorberechnete Hints. + /// Führt automatische Verknüpfung durch (LLM-basiert, ohne Rückfragen) /// public async Task RunAutomaticLinkingAsync(CancellationToken ct = default) { - try + if (!IsConfigured) { - var startTime = DateTimeOffset.UtcNow; - var linkedCount = 0; - var markedUnlinkedCount = 0; - var summaryLines = new List(); - - // 1. Lade gelernte Regeln - var rules = await _dbContext.AgentMemories - .Where(m => m.AgentKey == "transaction-linking") - .ToListAsync(ct); - - var skipPatterns = rules - .Where(r => r.MemoryType == AgentMemoryType.SkipPattern) - .Select(r => r.Key.ToLowerInvariant()) - .ToHashSet(); - - // 2. Lade alle unverknüpften Transaktionen - var unlinked = await _dbContext.CryptoTransactions - .Include(t => t.Wallet) - .Where(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked) - .OrderBy(t => t.DateTime) - .ToListAsync(ct); - - if (unlinked.Count == 0) - { - return new AutoLinkResult - { - LinkedCount = 0, - MarkedUnlinkedCount = 0, - RemainingUnlinkedCount = 0, - Summary = "Keine unverknüpften Transaktionen gefunden.", - Success = true - }; - } - - var sends = unlinked.Where(t => t.TransactionType == TransactionType.Send).ToList(); - var receives = unlinked.Where(t => t.TransactionType == TransactionType.Receive).ToList(); - - // 3. Automatische Verknüpfung basierend auf Regeln - // Zuerst: Bekannte externe Einnahmen markieren - foreach (var tx in receives.ToList()) - { - var comment = tx.Comment?.ToLowerInvariant() ?? ""; - - // Check gegen bekannte externe Muster - var isExternal = IsKnownExternalTransaction(comment, skipPatterns); - - if (isExternal.matched) - { - tx.IsIntentionallyUnlinked = true; - - var metadata = new TransactionLinkMetadata - { - TransactionId = tx.Id, - LinkType = TransactionLinkType.IntentionallyUnlinked | TransactionLinkType.AIAssisted, - Confidence = 1.0m, - Reason = isExternal.reason, - LinkedAt = DateTimeOffset.UtcNow, - IsConfirmed = true, - ConfirmedAt = DateTimeOffset.UtcNow - }; - _dbContext.TransactionLinkMetadata.Add(metadata); - - receives.Remove(tx); - markedUnlinkedCount++; - } - } - - // 4. Verknüpfe Send/Receive Paare basierend auf Zeit + Betrag - var linkedPairs = new List<(CryptoTransaction send, CryptoTransaction receive, string reason)>(); - - foreach (var send in sends.ToList()) - { - // Finde passendes Receive - var bestMatch = receives - .Where(r => - r.Symbol == send.Symbol && - r.DateTime >= send.DateTime.AddMinutes(-5) && - r.DateTime <= send.DateTime.AddHours(24)) - .Select(r => new - { - Receive = r, - TimeDiff = r.DateTime - send.DateTime, - AmountDiff = Math.Abs(send.QuantityAfterFee - r.Quantity), - AmountPercent = send.QuantityAfterFee > 0 - ? Math.Abs(send.QuantityAfterFee - r.Quantity) / send.QuantityAfterFee - : 1m - }) - .Where(m => m.AmountPercent < 0.01m) // Max 1% Differenz - .OrderBy(m => m.AmountDiff) - .ThenBy(m => m.TimeDiff) - .FirstOrDefault(); - - if (bestMatch != null) - { - var reason = $"Auto-Link: {send.Symbol} {send.Quantity:F8}, " + - $"Zeit: {bestMatch.TimeDiff.TotalMinutes:F0} Min, " + - $"Δ: {bestMatch.AmountDiff:F8}"; - - linkedPairs.Add((send, bestMatch.Receive, reason)); - sends.Remove(send); - receives.Remove(bestMatch.Receive); - } - } - - // 5. Führe die Verknüpfungen durch - foreach (var (send, receive, reason) in linkedPairs) + return new AutoLinkResult { - send.OppositeTransactionId = receive.Id; - send.OppositeWalletId = receive.WalletId; - receive.OppositeTransactionId = send.Id; - receive.OppositeWalletId = send.WalletId; - - var metadata = new TransactionLinkMetadata - { - TransactionId = send.Id, - LinkType = TransactionLinkType.AIAssisted, - Confidence = 0.95m, - Reason = reason, - LinkedAt = DateTimeOffset.UtcNow, - IsConfirmed = true, - ConfirmedAt = DateTimeOffset.UtcNow - }; - _dbContext.TransactionLinkMetadata.Add(metadata); - - linkedCount++; - } + Summary = "AI-Service nicht konfiguriert.", + Success = false + }; + } - await _dbContext.SaveChangesAsync(ct); + try + { + var before = await GetStatisticsAsync(ct); - // 6. Statistiken berechnen - var remainingUnlinked = sends.Count + receives.Count; + var agent = _agentBuilder.BuildFastAgent(TransactionLinkingAgentDefinition.KEY); + var result = await agent.RunAsync( + "Auto-Link Modus: Verknüpfe oder markiere nur, wenn du sehr sicher bist (Konfidenz >= 0.9). " + + "Keine Rückfragen stellen. Unsichere Fälle einfach überspringen.", + cancellationToken: ct); - // Zusammenfassung erstellen - summaryLines.Add($"✅ {linkedCount} Transaktionen automatisch verknüpft"); - if (markedUnlinkedCount > 0) - summaryLines.Add($"📥 {markedUnlinkedCount} als externe Einnahme markiert"); - if (remainingUnlinked > 0) - summaryLines.Add($"⏳ {remainingUnlinked} benötigen manuelle Prüfung"); + var after = await GetStatisticsAsync(ct); - // Details zu verknüpften Paaren - if (linkedPairs.Count > 0) - { - var bySymbol = linkedPairs.GroupBy(p => p.send.Symbol) - .Select(g => $"{g.Key}: {g.Count()}") - .ToList(); - summaryLines.Add($"Verknüpft nach Symbol: {string.Join(", ", bySymbol)}"); - } + var linkedCount = Math.Max(0, after.LinkedTransactions - before.LinkedTransactions); + var markedUnlinkedCount = Math.Max(0, after.IntentionallyUnlinked - before.IntentionallyUnlinked); return new AutoLinkResult { LinkedCount = linkedCount, MarkedUnlinkedCount = markedUnlinkedCount, - RemainingUnlinkedCount = remainingUnlinked, - Summary = string.Join("\n", summaryLines), + RemainingUnlinkedCount = after.UnlinkedTransactions, + Summary = string.IsNullOrWhiteSpace(result.Text) ? "Auto-Link abgeschlossen." : result.Text!, Success = true }; } @@ -285,47 +158,6 @@ public async Task RunAutomaticLinkingAsync(CancellationToken ct } } - /// - /// Prüft ob eine Transaktion basierend auf Kommentar als externe Einnahme erkannt wird - /// - private (bool matched, string reason) IsKnownExternalTransaction(string comment, HashSet customPatterns) - { - if (string.IsNullOrWhiteSpace(comment)) - return (false, ""); - - // Eingebaute Muster für externe Einnahmen - var builtInPatterns = new Dictionary - { - { "staking", "Staking Rewards" }, - { "eth 2.0 staking", "ETH 2.0 Staking Rewards" }, - { "airdrop", "Airdrop" }, - { "bonus", "Bonus" }, - { "referral", "Referral Bonus" }, - { "mining", "Mining Rewards" }, - { "lending", "Lending Interest" }, - { "interest", "Interest" }, - { "cashback", "Cashback" }, - { "reward", "Reward" }, - { "div. käufe", "Diverse Käufe (Fiat)" }, - { "kauf", "Kauf (Fiat)" }, - }; - - foreach (var (pattern, reason) in builtInPatterns) - { - if (comment.Contains(pattern, StringComparison.OrdinalIgnoreCase)) - return (true, reason); - } - - // Benutzerdefinierte Muster - foreach (var pattern in customPatterns) - { - if (comment.Contains(pattern, StringComparison.OrdinalIgnoreCase)) - return (true, $"Benutzerdefiniert: {pattern}"); - } - - return (false, ""); - } - /// /// Gibt Statistiken über unverknüpfte Transaktionen zurück diff --git a/src/CryptoTracker/Agent/Tools/AskLinkingQuestionTool.cs b/src/CryptoTracker/Agent/Tools/AskLinkingQuestionTool.cs new file mode 100644 index 0000000..76707d7 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/AskLinkingQuestionTool.cs @@ -0,0 +1,113 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using CryptoTracker.Shared; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool um den Benutzer eine Frage zu stellen (Transaction Linking). +/// +public sealed class AskLinkingQuestionTool : IAgentTool +{ + private const string FreeTextOptionLabel = "Andere Option (Freitext)"; + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILinkingAgentContextAccessor _contextAccessor; + + public AskLinkingQuestionTool( + CryptoTrackerDbContext dbContext, + ILinkingAgentContextAccessor contextAccessor) + { + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => AskUserAsync; + public string GetToolName() => "ask_user"; + public string GetToolDescription() => """ + Stellt dem Benutzer eine Frage. Verwende dieses Tool wenn du unsicher bist. + Parameter: + - transactionId: Optional, ID der betroffenen Transaktion + - question: Die Frage an den Benutzer + - options: Auswahlmöglichkeiten als Liste (mit '|' getrennt), z.B. "Option A | Option B | Überspringen" + + Hinweis: Die Freitext-Option wird automatisch hinzugefügt. + """; + + private async Task AskUserAsync( + [Description("Optional: Transaktions-ID")] int? transactionId, + [Description("Frage an den Benutzer")] string question, + [Description("Auswahlmöglichkeiten, getrennt mit '|'")] string options) + { + var context = _contextAccessor.Current; + if (context == null) + { + return JsonSerializer.Serialize(new { success = false, error = "Kein aktiver Session-Kontext" }); + } + + if (!context.AllowQuestions) + { + return JsonSerializer.Serialize(new { success = false, error = "Fragen sind in dieser Session nicht erlaubt" }); + } + + var optionList = ParseOptions(options); + if (!optionList.Contains(FreeTextOptionLabel, StringComparer.OrdinalIgnoreCase)) + { + optionList.Add(FreeTextOptionLabel); + } + + var questionId = Guid.NewGuid().ToString("N"); + UnlinkedTransactionDTO? transaction = null; + + if (transactionId.HasValue) + { + transaction = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Where(t => t.Id == transactionId.Value) + .Select(t => new UnlinkedTransactionDTO + { + Id = t.Id, + DateTime = t.DateTime, + Type = t.TransactionType.ToString(), + Symbol = t.Symbol, + Quantity = t.Quantity, + QuantityAfterFee = t.QuantityAfterFee, + Comment = t.Comment, + Address = t.Address, + WalletName = t.Wallet.Name, + TransactionId = t.TransactionId, + Network = t.Network + }) + .FirstOrDefaultAsync(); + } + + context.Session.CurrentQuestionId = questionId; + context.Session.CurrentQuestion = question; + context.Session.CurrentTransaction = transaction; + context.Session.CurrentOptions = optionList; + + await context.SendEventAsync(new LinkingEventDTO + { + EventType = "question", + QuestionId = questionId, + Message = question, + Transaction = transaction, + Options = optionList, + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + + return JsonSerializer.Serialize(new { success = true, questionId }); + } + + private static List ParseOptions(string options) + { + return options + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(o => !string.IsNullOrWhiteSpace(o)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/src/CryptoTracker/Agent/Tools/AskLotLinkingQuestionTool.cs b/src/CryptoTracker/Agent/Tools/AskLotLinkingQuestionTool.cs new file mode 100644 index 0000000..4d9531a --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/AskLotLinkingQuestionTool.cs @@ -0,0 +1,228 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using CryptoTracker.Shared; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool um den Benutzer eine Lot-Frage zu stellen. +/// +public sealed class AskLotLinkingQuestionTool : IAgentTool +{ + private const string FreeTextOptionLabel = "Andere Option (Freitext)"; + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILotLinkingAgentContextAccessor _contextAccessor; + + public AskLotLinkingQuestionTool( + CryptoTrackerDbContext dbContext, + ILotLinkingAgentContextAccessor contextAccessor) + { + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => AskLotQuestionAsync; + public string GetToolName() => "ask_lot_user"; + public string GetToolDescription() => """ + Stellt dem Benutzer eine Lot-Frage. + Parameter: + - assignmentType: "transaction" oder "trade" + - assignmentId: ID des Eintrags + - question: Die Frage + - options: Auswahlmöglichkeiten als Liste (mit '|' getrennt) + - lotWalletId: Optional, WalletId aus dem die Lots gewählt werden sollen + """; + + private async Task AskLotQuestionAsync( + [Description("assignmentType: transaction/trade")] string assignmentType, + [Description("assignmentId")] int assignmentId, + [Description("Frage")] string question, + [Description("Optionen mit '|' getrennt")] string options, + [Description("Optional: WalletId für Lot-Auswahl")] int? lotWalletId = null) + { + var context = _contextAccessor.Current; + if (context == null) + { + return JsonSerializer.Serialize(new { success = false, error = "Kein aktiver Session-Kontext" }); + } + + if (!context.AllowQuestions) + { + return JsonSerializer.Serialize(new { success = false, error = "Fragen sind in dieser Session nicht erlaubt" }); + } + + var optionList = ParseOptions(options); + if (!optionList.Contains(FreeTextOptionLabel, StringComparer.OrdinalIgnoreCase)) + { + optionList.Add(FreeTextOptionLabel); + } + + PendingLotAssignmentDTO? assignment = null; + string? symbol = null; + int? walletId = lotWalletId; + DateTimeOffset? maxAcquisitionDate = null; + + if (assignmentType.Equals("transaction", StringComparison.OrdinalIgnoreCase)) + { + var tx = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Include(t => t.OppositeWallet) + .FirstOrDefaultAsync(t => t.Id == assignmentId); + + if (tx != null) + { + assignment = new PendingLotAssignmentDTO( + "Transaction", + tx.Id, + tx.DateTime, + tx.Symbol, + tx.QuantityAfterFee, + tx.Wallet.Name, + tx.WalletId, + "Receive", + tx.OppositeWallet?.Name, + tx.Comment) + { + OppositeTransactionId = tx.OppositeTransactionId + }; + + symbol = tx.Symbol; + walletId ??= tx.WalletId; + maxAcquisitionDate = tx.DateTime; + } + } + else if (assignmentType.Equals("trade", StringComparison.OrdinalIgnoreCase)) + { + var trade = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == assignmentId); + + if (trade != null) + { + var quantity = trade.TradeType == TradeType.Buy ? trade.QuantityAfterFee : trade.Quantity; + assignment = new PendingLotAssignmentDTO( + "Trade", + trade.Id, + trade.DateTime, + trade.Symbol, + quantity, + trade.Wallet.Name, + trade.WalletId, + trade.TradeType.ToString(), + null, + trade.Comment) + { + OppositeTradeId = trade.OppositeTradeId, + OppositeSymbol = trade.OppositeSymbol + }; + + symbol = trade.Symbol; + walletId ??= trade.WalletId; + maxAcquisitionDate = trade.DateTime; + } + } + + var lotOptions = await LoadLotOptionsAsync(symbol, walletId, maxAcquisitionDate); + + var questionId = Guid.NewGuid().ToString("N"); + context.Session.CurrentQuestionId = questionId; + context.Session.CurrentQuestion = question; + context.Session.CurrentAssignment = assignment; + context.Session.CurrentOptions = optionList; + context.Session.CurrentLotOptions = lotOptions; + + await context.SendEventAsync(new LotLinkingEventDTO + { + EventType = "question", + QuestionId = questionId, + Message = question, + Assignment = assignment, + Options = optionList, + LotOptions = lotOptions, + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + + return JsonSerializer.Serialize(new { success = true, questionId }); + } + + private async Task> LoadLotOptionsAsync( + string? symbol, + int? walletId, + DateTimeOffset? maxAcquisitionDate) + { + if (string.IsNullOrWhiteSpace(symbol)) + { + return new List(); + } + + var query = _dbContext.AssetLots + .Include(l => l.CurrentWallet) + .Where(l => l.Symbol == symbol.ToUpperInvariant()) + .Where(l => l.RemainingQuantity > 0); + + if (walletId.HasValue) + { + query = query.Where(l => l.CurrentWalletId == walletId.Value); + } + + if (maxAcquisitionDate.HasValue) + { + query = query.Where(l => l.AcquisitionDate <= maxAcquisitionDate.Value); + } + + var lots = await query + .OrderBy(l => l.AcquisitionDate) + .Take(200) + .ToListAsync(); + + var result = new List(); + foreach (var lot in lots) + { + var rootId = await GetRootLotIdAsync(lot); + var rootLabel = rootId == lot.Id ? $"Root #{rootId}" : $"Root #{rootId} → Lot #{lot.Id}"; + var altLabel = lot.IsAltbestand ? "Altbestand" : "Neubestand"; + + result.Add(new LotOptionDTO + { + LotId = lot.Id, + DisplayText = $"{rootLabel} | {lot.RemainingQuantity:F8} {lot.Symbol} | {altLabel} | {lot.AcquisitionDate:yyyy-MM-dd} | {lot.CurrentWallet.Name}", + AvailableQuantity = lot.RemainingQuantity, + AcquisitionDate = lot.AcquisitionDate, + AcquisitionPriceEur = lot.AcquisitionPriceEur, + IsAltbestand = lot.IsAltbestand, + WalletName = lot.CurrentWallet.Name + }); + } + + return result; + } + + private async Task GetRootLotIdAsync(AssetLot lot) + { + var current = lot; + while (current.ParentLotId.HasValue) + { + var parent = await _dbContext.AssetLots.FindAsync(current.ParentLotId.Value); + if (parent == null) + { + break; + } + current = parent; + } + + return current.Id; + } + + private static List ParseOptions(string options) + { + return options + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(o => !string.IsNullOrWhiteSpace(o)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/src/CryptoTracker/Agent/Tools/CreateRootLotTool.cs b/src/CryptoTracker/Agent/Tools/CreateRootLotTool.cs new file mode 100644 index 0000000..5d1315a --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/CreateRootLotTool.cs @@ -0,0 +1,165 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using CryptoTracker.Services; +using CryptoTracker.Shared; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool um Root-Lots zu erstellen (Fiat-Kauf oder externe Einzahlung). +/// +public sealed class CreateRootLotTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + private readonly LotService _lotService; + private readonly CoinRateService _coinRateService; + private readonly ILotLinkingAgentContextAccessor _contextAccessor; + + public CreateRootLotTool( + CryptoTrackerDbContext dbContext, + LotService lotService, + CoinRateService coinRateService, + ILotLinkingAgentContextAccessor contextAccessor) + { + _dbContext = dbContext; + _lotService = lotService; + _coinRateService = coinRateService; + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => CreateRootLotAsync; + public string GetToolName() => "create_root_lot"; + public string GetToolDescription() => """ + Erstellt ein Root-Lot aus Fiat-Kauf oder externer Einzahlung. + Parameter: + - sourceType: "trade" oder "transaction" + - sourceId: ID des Trades/der Transaktion + - acquisitionType: FiatPurchase, ExternalDeposit, Mining, Staking, Airdrop, Gift, Manual + - acquisitionPriceEur: Optional, wenn bekannt + - note: Optional + """; + + private async Task CreateRootLotAsync( + [Description("Quelle: trade/transaction")] string sourceType, + [Description("ID der Quelle")] int sourceId, + [Description("AcquisitionType")] string acquisitionType, + [Description("Optional: Preis in EUR")] decimal? acquisitionPriceEur = null, + [Description("Optional: Notiz")] string? note = null) + { + if (!Enum.TryParse(acquisitionType, true, out var lotType)) + { + return JsonSerializer.Serialize(new { success = false, error = $"Ungültiger acquisitionType: {acquisitionType}" }); + } + + AssetLot lot; + if (sourceType.Equals("trade", StringComparison.OrdinalIgnoreCase)) + { + var trade = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == sourceId); + + if (trade == null) + return JsonSerializer.Serialize(new { success = false, error = $"Trade {sourceId} nicht gefunden" }); + + if (trade.TradeType != TradeType.Buy) + return JsonSerializer.Serialize(new { success = false, error = "Trade ist kein Buy" }); + + var eurPrice = acquisitionPriceEur ?? await GuessEurPriceForTradeAsync(trade); + lot = await _lotService.CreateLotFromFiatPurchaseAsync(trade, eurPrice); + } + else if (sourceType.Equals("transaction", StringComparison.OrdinalIgnoreCase)) + { + var tx = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == sourceId); + + if (tx == null) + return JsonSerializer.Serialize(new { success = false, error = $"Transaktion {sourceId} nicht gefunden" }); + + var eurPrice = acquisitionPriceEur ?? await GuessEurPriceForSymbolAsync(tx.Symbol, tx.DateTime.UtcDateTime); + + lot = await _lotService.CreateManualLotAsync(new ManualLotRequest + { + Symbol = tx.Symbol, + WalletId = tx.WalletId, + Quantity = tx.QuantityAfterFee, + AcquisitionDate = tx.DateTime, + AcquisitionPriceEur = eurPrice, + AcquisitionType = lotType, + SourceTransactionId = tx.Id, + Note = note + }); + } + else + { + return JsonSerializer.Serialize(new { success = false, error = "sourceType muss 'trade' oder 'transaction' sein" }); + } + + var context = _contextAccessor.Current; + if (context != null) + { + context.Session.CreatedLotsCount++; + context.Session.AssignedCount++; + context.Session.ProcessedCount = context.Session.AssignedCount + context.Session.SkippedCount; + + await context.SendEventAsync(new LotLinkingEventDTO + { + EventType = "lot_created", + Message = $"Root-Lot erstellt: #{lot.Id} {lot.Symbol} {lot.OriginalQuantity:F8}", + CreatedLot = MapToDto(lot), + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + } + + return JsonSerializer.Serialize(new { success = true, lotId = lot.Id }); + } + + private async Task GuessEurPriceForTradeAsync(CryptoTrade trade) + { + if (trade.OppositeSymbol.Equals("EUR", StringComparison.OrdinalIgnoreCase)) + { + return trade.Price; + } + + var (fiatRate, _) = await _coinRateService.GetPreviousCloseRateWithSourceAsync( + trade.OppositeSymbol, + trade.DateTime.UtcDateTime); + if (fiatRate.HasValue && fiatRate.Value > 0) + { + return trade.Price * fiatRate.Value; + } + + var cryptoRate = await GuessEurPriceForSymbolAsync(trade.Symbol, trade.DateTime.UtcDateTime); + return cryptoRate > 0 ? cryptoRate : trade.Price; + } + + private async Task GuessEurPriceForSymbolAsync(string symbol, DateTime dateUtc) + { + var (rate, _) = await _coinRateService.GetPreviousCloseRateWithSourceAsync(symbol, dateUtc); + return rate ?? 0m; + } + + private static LotDTO MapToDto(AssetLot lot) => new( + lot.Id, + lot.Symbol, + lot.CurrentWalletId, + lot.CurrentWallet?.Name ?? string.Empty, + lot.RemainingQuantity, + lot.OriginalQuantity, + lot.AcquisitionDate, + lot.AcquisitionPriceEur, + lot.TotalAcquisitionCostEur, + lot.AcquisitionType.ToString(), + lot.IsAltbestand, + lot.IsFullyConsumed, + lot.Note, + lot.ParentLotId, + lot.SourceTradeId, + lot.SourceTransactionId, + lot.IsFlowComplete, + lot.FlowIncompleteReason); +} diff --git a/src/CryptoTracker/Agent/Tools/GetLotMemoryTool.cs b/src/CryptoTracker/Agent/Tools/GetLotMemoryTool.cs new file mode 100644 index 0000000..3f718cc --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/GetLotMemoryTool.cs @@ -0,0 +1,68 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Laden von Lot-Linking-Regeln aus dem Gedächtnis. +/// +public sealed class GetLotMemoryTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public GetLotMemoryTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => GetMemoryAsync; + public string GetToolName() => "get_lot_memory"; + public string GetToolDescription() => """ + Lädt gespeicherte Lot-Linking-Regeln. + Parameter: + - memoryType: Optional, filtert nach Art (SkipPattern, WalletMapping, etc.) + """; + + private async Task GetMemoryAsync( + [Description("Optional: SkipPattern, WalletMapping, AddressMapping, UserDecision, ImportErrorPattern")] string? memoryType = null) + { + const string agentKey = "lot-linking"; + + var query = _dbContext.AgentMemories + .Where(m => m.AgentKey == agentKey); + + if (!string.IsNullOrEmpty(memoryType) && Enum.TryParse(memoryType, true, out var type)) + { + query = query.Where(m => m.MemoryType == type); + } + + var memories = await query + .OrderByDescending(m => m.UsageCount) + .ThenByDescending(m => m.CreatedAt) + .Select(m => new + { + m.Id, + MemoryType = m.MemoryType.ToString(), + m.Key, + m.Value, + m.Description, + CreatedAt = m.CreatedAt.ToString("yyyy-MM-dd HH:mm"), + m.UsageCount + }) + .ToListAsync(); + + var summary = memories + .GroupBy(m => m.MemoryType) + .ToDictionary(g => g.Key, g => g.Count()); + + return JsonSerializer.Serialize(new + { + totalCount = memories.Count, + summary, + memories + }, new JsonSerializerOptions { WriteIndented = false }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/GetLotOptionsTool.cs b/src/CryptoTracker/Agent/Tools/GetLotOptionsTool.cs new file mode 100644 index 0000000..dc45189 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/GetLotOptionsTool.cs @@ -0,0 +1,107 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using CryptoTracker.Shared; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Laden verfügbarer Lots für eine Zuordnung. +/// +public sealed class GetLotOptionsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public GetLotOptionsTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => GetLotOptionsAsync; + public string GetToolName() => "get_lot_options"; + public string GetToolDescription() => """ + Lädt verfügbare Lots für ein Symbol (optional gefiltert nach Wallet). + Parameter: + - symbol: z.B. "BTC" + - walletId: Optional, nur Lots dieses Wallets + - maxAcquisitionDate: Optional, nur Lots mit AcquisitionDate <= Datum + - limit: max Anzahl (default 200) + """; + + private async Task GetLotOptionsAsync( + [Description("Symbol, z.B. BTC")] string symbol, + [Description("Optional: WalletId")] int? walletId = null, + [Description("Max Anzahl")] int limit = 200, + [Description("Optional: Max AcquisitionDate (ISO)")] DateTimeOffset? maxAcquisitionDate = null) + { + if (string.IsNullOrWhiteSpace(symbol)) + { + return JsonSerializer.Serialize(new { error = "Symbol fehlt" }); + } + + limit = Math.Clamp(limit, 1, 500); + + var query = _dbContext.AssetLots + .Include(l => l.CurrentWallet) + .Where(l => l.Symbol == symbol.ToUpperInvariant()) + .Where(l => l.RemainingQuantity > 0); + + if (walletId.HasValue) + { + query = query.Where(l => l.CurrentWalletId == walletId.Value); + } + + if (maxAcquisitionDate.HasValue) + { + query = query.Where(l => l.AcquisitionDate <= maxAcquisitionDate.Value); + } + + var lots = await query + .OrderBy(l => l.AcquisitionDate) + .Take(limit) + .ToListAsync(); + + var options = new List(); + foreach (var lot in lots) + { + var rootId = await GetRootLotIdAsync(lot); + var rootLabel = rootId == lot.Id ? $"Root #{rootId}" : $"Root #{rootId} → Lot #{lot.Id}"; + var altLabel = lot.IsAltbestand ? "Altbestand" : "Neubestand"; + + options.Add(new LotOptionDTO + { + LotId = lot.Id, + DisplayText = $"{rootLabel} | {lot.RemainingQuantity:F8} {lot.Symbol} | {altLabel} | {lot.AcquisitionDate:yyyy-MM-dd} | {lot.CurrentWallet.Name}", + AvailableQuantity = lot.RemainingQuantity, + AcquisitionDate = lot.AcquisitionDate, + AcquisitionPriceEur = lot.AcquisitionPriceEur, + IsAltbestand = lot.IsAltbestand, + WalletName = lot.CurrentWallet.Name + }); + } + + return JsonSerializer.Serialize(new + { + totalCount = options.Count, + options + }, new JsonSerializerOptions { WriteIndented = false }); + } + + private async Task GetRootLotIdAsync(AssetLot lot) + { + var current = lot; + while (current.ParentLotId.HasValue) + { + var parent = await _dbContext.AssetLots.FindAsync(current.ParentLotId.Value); + if (parent == null) + { + break; + } + current = parent; + } + + return current.Id; + } +} diff --git a/src/CryptoTracker/Agent/Tools/GetPendingLotAssignmentsTool.cs b/src/CryptoTracker/Agent/Tools/GetPendingLotAssignmentsTool.cs new file mode 100644 index 0000000..7474922 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/GetPendingLotAssignmentsTool.cs @@ -0,0 +1,99 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using CryptoTracker.Services; +using CryptoTracker.Shared; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Laden ausstehender Lot-Zuordnungen. +/// +public sealed class GetPendingLotAssignmentsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public GetPendingLotAssignmentsTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => GetPendingAssignmentsAsync; + public string GetToolName() => "get_pending_assignments"; + public string GetToolDescription() => """ + Lädt alle ausstehenden Lot-Zuordnungen (Receive-Transaktionen + Sell-Trades). + Parameter: + - limit: max Anzahl (default 200) + - offset: Startoffset (default 0) + """; + + private async Task GetPendingAssignmentsAsync( + [Description("Max Anzahl")] int limit = 200, + [Description("Offset")] int offset = 0) + { + limit = Math.Clamp(limit, 1, 500); + offset = Math.Max(0, offset); + + var transactions = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .Include(t => t.OppositeWallet) + .Where(t => t.TransactionType == TransactionType.Receive && !t.LotAssignmentConfirmed) + .Select(t => new PendingLotAssignmentDTO( + "Transaction", + t.Id, + t.DateTime, + t.Symbol, + t.QuantityAfterFee, + t.Wallet.Name, + t.WalletId, + "Receive", + t.OppositeWallet != null ? t.OppositeWallet.Name : null, + t.Comment) + { + OppositeTransactionId = t.OppositeTransactionId + }) + .ToListAsync(); + + var trades = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .Where(t => + (t.TradeType == TradeType.Sell && !t.LotAssignmentConfirmed) || + (t.TradeType == TradeType.Buy + && t.ResultingLotId == null + && FiatSymbols.ForQuery.Contains(t.OppositeSymbol))) + .Select(t => new PendingLotAssignmentDTO( + "Trade", + t.Id, + t.DateTime, + t.Symbol, + t.TradeType == TradeType.Buy ? t.QuantityAfterFee : t.Quantity, + t.Wallet.Name, + t.WalletId, + t.TradeType.ToString(), + null, + t.Comment) + { + OppositeTradeId = t.OppositeTradeId, + OppositeSymbol = t.OppositeSymbol + }) + .ToListAsync(); + + var all = transactions + .Concat(trades) + .OrderBy(a => a.DateTime) + .ToList(); + + var totalCount = all.Count; + var paged = all.Skip(offset).Take(limit).ToList(); + + return JsonSerializer.Serialize(new + { + totalCount, + offset, + limit, + assignments = paged + }, new JsonSerializerOptions { WriteIndented = false }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/GetTradeDetailsTool.cs b/src/CryptoTracker/Agent/Tools/GetTradeDetailsTool.cs new file mode 100644 index 0000000..d848f7e --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/GetTradeDetailsTool.cs @@ -0,0 +1,67 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Laden von Trade-Details. +/// +public sealed class GetTradeDetailsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + + public GetTradeDetailsTool(CryptoTrackerDbContext dbContext) + { + _dbContext = dbContext; + } + + public Delegate GetToolRunner() => GetTradeDetailsAsync; + public string GetToolName() => "get_trade_details"; + public string GetToolDescription() => """ + Lädt Details zu einem oder mehreren Trades. + Parameter: + - tradeIds: Komma-separierte Liste von IDs (z.B. "1,2,3") + """; + + private async Task GetTradeDetailsAsync( + [Description("Komma-separierte IDs")] string tradeIds) + { + var ids = tradeIds + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var id) ? id : (int?)null) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + + if (!ids.Any()) + { + return JsonSerializer.Serialize(new { error = "Keine gültigen IDs angegeben" }); + } + + var trades = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .Where(t => ids.Contains(t.Id)) + .Select(t => new + { + t.Id, + DateTime = t.DateTime.ToString("yyyy-MM-dd HH:mm:ss zzz"), + TradeType = t.TradeType.ToString(), + t.Symbol, + t.OppositeSymbol, + t.Price, + t.Quantity, + t.QuantityAfterFee, + t.Fee, + WalletId = t.WalletId, + WalletName = t.Wallet.Name, + t.OppositeTradeId, + t.Comment + }) + .ToListAsync(); + + return JsonSerializer.Serialize(trades, new JsonSerializerOptions { WriteIndented = false }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/LinkTransactionsTool.cs b/src/CryptoTracker/Agent/Tools/LinkTransactionsTool.cs index c3efdf1..071fde6 100644 --- a/src/CryptoTracker/Agent/Tools/LinkTransactionsTool.cs +++ b/src/CryptoTracker/Agent/Tools/LinkTransactionsTool.cs @@ -1,5 +1,6 @@ using CryptoTracker.Agent.Common; using CryptoTracker.Entities; +using CryptoTracker.Shared; using Microsoft.EntityFrameworkCore; using System.ComponentModel; using System.Text.Json; @@ -12,10 +13,14 @@ namespace CryptoTracker.Agent.Tools; public sealed class LinkTransactionsTool : IAgentTool { private readonly CryptoTrackerDbContext _dbContext; + private readonly ILinkingAgentContextAccessor _contextAccessor; - public LinkTransactionsTool(CryptoTrackerDbContext dbContext) + public LinkTransactionsTool( + CryptoTrackerDbContext dbContext, + ILinkingAgentContextAccessor contextAccessor) { _dbContext = dbContext; + _contextAccessor = contextAccessor; } public Delegate GetToolRunner() => LinkTransactionsAsync; @@ -101,6 +106,25 @@ private async Task LinkTransactionsAsync( await _dbContext.SaveChangesAsync(); + var context = _contextAccessor.Current; + if (context != null) + { + context.Session.LinkedCount++; + context.Session.ProcessedCount = context.Session.LinkedCount + + context.Session.MarkedExternalCount + + context.Session.SkippedCount; + + await context.SendEventAsync(new LinkingEventDTO + { + EventType = "linked", + Message = $"Verknüpft: {send.Symbol} {send.QuantityAfterFee:F8} ({send.Wallet.Name} → {receive.Wallet.Name})", + SendId = send.Id, + ReceiveId = receive.Id, + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + } + return JsonSerializer.Serialize(new { success = true, diff --git a/src/CryptoTracker/Agent/Tools/LinkVirtualWalletTool.cs b/src/CryptoTracker/Agent/Tools/LinkVirtualWalletTool.cs new file mode 100644 index 0000000..f1448e2 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/LinkVirtualWalletTool.cs @@ -0,0 +1,161 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using CryptoTracker.Shared; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool um ein virtuelles Gegenstück zu erstellen und zu verknüpfen. +/// +public sealed class LinkVirtualWalletTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILinkingAgentContextAccessor _contextAccessor; + + public LinkVirtualWalletTool( + CryptoTrackerDbContext dbContext, + ILinkingAgentContextAccessor contextAccessor) + { + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => LinkVirtualWalletAsync; + public string GetToolName() => "link_virtual_wallet"; + public string GetToolDescription() => """ + Erstellt ein virtuelles Gegenstück und verknüpft es mit einer Transaktion. + Parameter: + - transactionId: ID der bestehenden Transaktion + - virtualWalletId: Optional, ID eines vorhandenen virtuellen Wallets + - virtualWalletName: Optional, Name eines neuen virtuellen Wallets + - reason: Optional, Begründung für die Verknüpfung + """; + + private async Task LinkVirtualWalletAsync( + [Description("Transaktions-ID")] int transactionId, + [Description("Optional: Virtuelles Wallet ID")] int? virtualWalletId, + [Description("Optional: Virtuelles Wallet Name")] string? virtualWalletName, + [Description("Optional: Begründung")] string? reason = null) + { + var tx = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == transactionId); + + if (tx == null) + { + return JsonSerializer.Serialize(new { success = false, error = $"Transaktion {transactionId} nicht gefunden" }); + } + + if (tx.OppositeTransactionId != null) + { + return JsonSerializer.Serialize(new { success = false, error = "Transaktion ist bereits verknüpft" }); + } + + var virtualWallet = await ResolveVirtualWalletAsync(virtualWalletId, virtualWalletName); + if (virtualWallet == null) + { + return JsonSerializer.Serialize(new { success = false, error = "Virtuelles Wallet konnte nicht gefunden oder erstellt werden" }); + } + + var oppositeType = tx.TransactionType == TransactionType.Send ? TransactionType.Receive : TransactionType.Send; + var oppositeQuantity = tx.TransactionType == TransactionType.Send ? tx.QuantityAfterFee : tx.Quantity; + + if (oppositeQuantity <= 0) + { + return JsonSerializer.Serialize(new { success = false, error = "Ungültige Menge für virtuelles Gegenstück" }); + } + + var oppositeTransaction = new CryptoTransaction + { + WalletId = virtualWallet.Id, + DateTime = tx.DateTime, + TransactionType = oppositeType, + Symbol = tx.Symbol, + Quantity = oppositeQuantity, + Fee = 0m, + Comment = $"Virtuelles Gegenstück zu #{tx.Id} ({tx.Wallet.Name})", + Address = tx.Address, + Network = tx.Network, + TransactionId = tx.TransactionId + }; + + _dbContext.CryptoTransactions.Add(oppositeTransaction); + await _dbContext.SaveChangesAsync(); + + tx.OppositeTransactionId = oppositeTransaction.Id; + tx.OppositeWalletId = oppositeTransaction.WalletId; + oppositeTransaction.OppositeTransactionId = tx.Id; + oppositeTransaction.OppositeWalletId = tx.WalletId; + + var metadata = new TransactionLinkMetadata + { + TransactionId = tx.Id, + LinkType = TransactionLinkType.AIAssisted, + Confidence = 0.9m, + Reason = reason ?? $"Virtuelles Wallet: {virtualWallet.Name}", + LinkedAt = DateTimeOffset.UtcNow, + IsConfirmed = true, + ConfirmedAt = DateTimeOffset.UtcNow + }; + _dbContext.TransactionLinkMetadata.Add(metadata); + + await _dbContext.SaveChangesAsync(); + + var context = _contextAccessor.Current; + if (context != null) + { + context.Session.LinkedCount++; + context.Session.ProcessedCount = context.Session.LinkedCount + + context.Session.MarkedExternalCount + + context.Session.SkippedCount; + + await context.SendEventAsync(new LinkingEventDTO + { + EventType = "linked", + Message = $"Verknüpft (virtuell): {tx.Symbol} {oppositeQuantity:F8} ({tx.Wallet.Name} → {virtualWallet.Name})", + SendId = tx.TransactionType == TransactionType.Send ? tx.Id : oppositeTransaction.Id, + ReceiveId = tx.TransactionType == TransactionType.Receive ? tx.Id : oppositeTransaction.Id, + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + } + + return JsonSerializer.Serialize(new { success = true, virtualWalletId = virtualWallet.Id }); + } + + private async Task ResolveVirtualWalletAsync(int? walletId, string? walletName) + { + if (walletId.HasValue) + { + return await _dbContext.Wallets + .FirstOrDefaultAsync(w => w.Id == walletId.Value && w.IsVirtual); + } + + var name = walletName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + return null; + } + + var existing = await _dbContext.Wallets + .FirstOrDefaultAsync(w => w.Name == name); + + if (existing != null) + { + return existing.IsVirtual ? existing : null; + } + + var wallet = new Wallet + { + Name = name, + IsVirtual = true + }; + + _dbContext.Wallets.Add(wallet); + await _dbContext.SaveChangesAsync(); + return wallet; + } +} diff --git a/src/CryptoTracker/Agent/Tools/LogLinkingEventTool.cs b/src/CryptoTracker/Agent/Tools/LogLinkingEventTool.cs new file mode 100644 index 0000000..3667abf --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/LogLinkingEventTool.cs @@ -0,0 +1,51 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Shared; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool für Info/Status-Events während des Linking-Prozesses. +/// +public sealed class LogLinkingEventTool : IAgentTool +{ + private readonly ILinkingAgentContextAccessor _contextAccessor; + + public LogLinkingEventTool(ILinkingAgentContextAccessor contextAccessor) + { + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => LogEventAsync; + public string GetToolName() => "log_linking_event"; + public string GetToolDescription() => """ + Sendet eine Status-/Info-Nachricht an die UI. + Parameter: + - message: Text der Info + - eventType: Optional, z.B. "info" oder "progress" (default: "info") + """; + + private async Task LogEventAsync( + [Description("Nachricht")] string message, + [Description("EventType, z.B. info/progress")] string? eventType = null) + { + var context = _contextAccessor.Current; + if (context == null) + { + return JsonSerializer.Serialize(new { success = false, error = "Kein aktiver Session-Kontext" }); + } + + var type = string.IsNullOrWhiteSpace(eventType) ? "info" : eventType.Trim(); + + await context.SendEventAsync(new LinkingEventDTO + { + EventType = type, + Message = message, + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + + return JsonSerializer.Serialize(new { success = true }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/LogLotLinkingEventTool.cs b/src/CryptoTracker/Agent/Tools/LogLotLinkingEventTool.cs new file mode 100644 index 0000000..50492d4 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/LogLotLinkingEventTool.cs @@ -0,0 +1,51 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Shared; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool für Info/Status-Events während des Lot-Linking. +/// +public sealed class LogLotLinkingEventTool : IAgentTool +{ + private readonly ILotLinkingAgentContextAccessor _contextAccessor; + + public LogLotLinkingEventTool(ILotLinkingAgentContextAccessor contextAccessor) + { + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => LogEventAsync; + public string GetToolName() => "log_lot_event"; + public string GetToolDescription() => """ + Sendet eine Status-/Info-Nachricht an die UI. + Parameter: + - message: Text der Info + - eventType: Optional, z.B. "info" oder "progress" (default: "info") + """; + + private async Task LogEventAsync( + [Description("Nachricht")] string message, + [Description("EventType")] string? eventType = null) + { + var context = _contextAccessor.Current; + if (context == null) + { + return JsonSerializer.Serialize(new { success = false, error = "Kein aktiver Session-Kontext" }); + } + + var type = string.IsNullOrWhiteSpace(eventType) ? "info" : eventType.Trim(); + + await context.SendEventAsync(new LotLinkingEventDTO + { + EventType = type, + Message = message, + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + + return JsonSerializer.Serialize(new { success = true }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/MarkAsIntentionallyUnlinkedTool.cs b/src/CryptoTracker/Agent/Tools/MarkAsIntentionallyUnlinkedTool.cs index b1c1a28..cf75084 100644 --- a/src/CryptoTracker/Agent/Tools/MarkAsIntentionallyUnlinkedTool.cs +++ b/src/CryptoTracker/Agent/Tools/MarkAsIntentionallyUnlinkedTool.cs @@ -1,5 +1,6 @@ using CryptoTracker.Agent.Common; using CryptoTracker.Entities; +using CryptoTracker.Shared; using Microsoft.EntityFrameworkCore; using System.ComponentModel; using System.Text.Json; @@ -12,10 +13,14 @@ namespace CryptoTracker.Agent.Tools; public sealed class MarkAsIntentionallyUnlinkedTool : IAgentTool { private readonly CryptoTrackerDbContext _dbContext; + private readonly ILinkingAgentContextAccessor _contextAccessor; - public MarkAsIntentionallyUnlinkedTool(CryptoTrackerDbContext dbContext) + public MarkAsIntentionallyUnlinkedTool( + CryptoTrackerDbContext dbContext, + ILinkingAgentContextAccessor contextAccessor) { _dbContext = dbContext; + _contextAccessor = contextAccessor; } public Delegate GetToolRunner() => MarkAsIntentionallyUnlinkedAsync; @@ -47,6 +52,7 @@ private async Task MarkAsIntentionallyUnlinkedAsync( return JsonSerializer.Serialize(new { success = false, error = "Keine gültigen IDs angegeben" }); var transactions = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) .Where(t => ids.Contains(t.Id)) .Where(t => t.OppositeTransactionId == null) // Nur unverknüpfte .ToListAsync(); @@ -76,6 +82,40 @@ private async Task MarkAsIntentionallyUnlinkedAsync( await _dbContext.SaveChangesAsync(); + var context = _contextAccessor.Current; + if (context != null) + { + foreach (var tx in transactions) + { + context.Session.MarkedExternalCount++; + context.Session.ProcessedCount = context.Session.LinkedCount + + context.Session.MarkedExternalCount + + context.Session.SkippedCount; + + await context.SendEventAsync(new LinkingEventDTO + { + EventType = "marked_external", + Message = $"Extern: {tx.Symbol} {tx.QuantityAfterFee:F8} - {reason}", + Transaction = new UnlinkedTransactionDTO + { + Id = tx.Id, + DateTime = tx.DateTime, + Type = tx.TransactionType.ToString(), + Symbol = tx.Symbol, + Quantity = tx.Quantity, + QuantityAfterFee = tx.QuantityAfterFee, + Comment = tx.Comment, + Address = tx.Address, + WalletName = tx.Wallet.Name, + TransactionId = tx.TransactionId, + Network = tx.Network + }, + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + } + } + return JsonSerializer.Serialize(new { success = true, diff --git a/src/CryptoTracker/Agent/Tools/SaveAgentMemoryTool.cs b/src/CryptoTracker/Agent/Tools/SaveAgentMemoryTool.cs index 7e1cf8c..ba4bff1 100644 --- a/src/CryptoTracker/Agent/Tools/SaveAgentMemoryTool.cs +++ b/src/CryptoTracker/Agent/Tools/SaveAgentMemoryTool.cs @@ -12,10 +12,14 @@ namespace CryptoTracker.Agent.Tools; public sealed class SaveAgentMemoryTool : IAgentTool { private readonly CryptoTrackerDbContext _dbContext; + private readonly ILinkingAgentContextAccessor _contextAccessor; - public SaveAgentMemoryTool(CryptoTrackerDbContext dbContext) + public SaveAgentMemoryTool( + CryptoTrackerDbContext dbContext, + ILinkingAgentContextAccessor contextAccessor) { _dbContext = dbContext; + _contextAccessor = contextAccessor; } public Delegate GetToolRunner() => SaveAgentMemoryAsync; @@ -44,6 +48,16 @@ private async Task SaveAgentMemoryAsync( [Description("Wert (z.B. 'skip', WalletId)")] string value, [Description("Menschenlesbare Beschreibung")] string description) { + var context = _contextAccessor.Current; + if (context == null || !context.AllowMemorySave) + { + return JsonSerializer.Serialize(new + { + success = false, + error = "Speichern nicht erlaubt (Benutzer hat 'Merken' nicht aktiviert)" + }); + } + if (!Enum.TryParse(memoryType, true, out var type)) return JsonSerializer.Serialize(new { success = false, error = $"Ungültiger memoryType: {memoryType}" }); diff --git a/src/CryptoTracker/Agent/Tools/SaveLotMemoryTool.cs b/src/CryptoTracker/Agent/Tools/SaveLotMemoryTool.cs new file mode 100644 index 0000000..b518ee2 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/SaveLotMemoryTool.cs @@ -0,0 +1,92 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool zum Speichern von Lot-Linking-Regeln im Gedächtnis. +/// +public sealed class SaveLotMemoryTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILotLinkingAgentContextAccessor _contextAccessor; + + public SaveLotMemoryTool( + CryptoTrackerDbContext dbContext, + ILotLinkingAgentContextAccessor contextAccessor) + { + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => SaveMemoryAsync; + public string GetToolName() => "save_lot_memory"; + public string GetToolDescription() => """ + Speichert eine Regel im Lot-Linking-Gedächtnis. + Parameter: + - memoryType: SkipPattern, WalletMapping, AddressMapping, UserDecision, ImportErrorPattern + - key: Schlüssel + - value: Wert + - description: Beschreibung + """; + + private async Task SaveMemoryAsync( + [Description("Art: SkipPattern, WalletMapping, AddressMapping, UserDecision, ImportErrorPattern")] string memoryType, + [Description("Schlüssel")] string key, + [Description("Wert")] string value, + [Description("Beschreibung")] string description) + { + var context = _contextAccessor.Current; + if (context == null || !context.AllowMemorySave) + { + return JsonSerializer.Serialize(new { success = false, error = "Speichern nicht erlaubt (Benutzer hat 'Merken' nicht aktiviert)" }); + } + + if (!Enum.TryParse(memoryType, true, out var type)) + return JsonSerializer.Serialize(new { success = false, error = $"Ungültiger memoryType: {memoryType}" }); + + if (string.IsNullOrWhiteSpace(key)) + return JsonSerializer.Serialize(new { success = false, error = "Key darf nicht leer sein" }); + + const string agentKey = "lot-linking"; + + var existing = await _dbContext.AgentMemories + .FirstOrDefaultAsync(m => m.AgentKey == agentKey && m.MemoryType == type && m.Key == key); + + if (existing != null) + { + existing.Value = value; + existing.Description = description; + existing.UsageCount++; + } + else + { + var memory = new AgentMemory + { + AgentKey = agentKey, + MemoryType = type, + Key = key, + Value = value, + Description = description, + CreatedAt = DateTimeOffset.UtcNow, + UsageCount = 0 + }; + _dbContext.AgentMemories.Add(memory); + } + + await _dbContext.SaveChangesAsync(); + + return JsonSerializer.Serialize(new + { + success = true, + message = existing != null ? $"Regel aktualisiert: {type} '{key}'" : $"Neue Regel gespeichert: {type} '{key}'", + memoryType = type.ToString(), + key, + value, + isUpdate = existing != null + }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/SellLotsTool.cs b/src/CryptoTracker/Agent/Tools/SellLotsTool.cs new file mode 100644 index 0000000..bb6b0bd --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/SellLotsTool.cs @@ -0,0 +1,190 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using CryptoTracker.Services; +using CryptoTracker.Shared; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool um Lots bei einem Fiat-Verkauf zuzuordnen. +/// +public sealed class SellLotsTool : IAgentTool +{ + private const string AutoFifoToken = "AUTO_FIFO"; + private readonly LotService _lotService; + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILotLinkingAgentContextAccessor _contextAccessor; + + public SellLotsTool( + LotService lotService, + CryptoTrackerDbContext dbContext, + ILotLinkingAgentContextAccessor contextAccessor) + { + _lotService = lotService; + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => SellLotsAsync; + public string GetToolName() => "sell_lots"; + public string GetToolDescription() => """ + Ordnet Lots für einen Fiat-Verkauf zu. + Parameter: + - tradeId: ID des Sell-Trades + - salePriceEurPerUnit: Verkaufspreis in EUR pro Einheit + - allocationsJson: JSON-Array mit { lotId, quantity } oder leer/"AUTO_FIFO" für FIFO + """; + + private async Task SellLotsAsync( + [Description("Trade ID")] int tradeId, + [Description("Verkaufspreis EUR pro Einheit")] decimal salePriceEurPerUnit, + [Description("JSON-Array {lotId, quantity}")] string allocationsJson) + { + List allocations; + if (ShouldUseAutoFifo(allocationsJson)) + { + var autoResult = await BuildAutoFifoAllocationsForSellAsync(tradeId); + if (autoResult.MissingQuantity > 0) + { + return JsonSerializer.Serialize(new + { + success = false, + error = $"FIFO unvollständig: es fehlen {autoResult.MissingQuantity:F8}" + }); + } + + allocations = autoResult.Allocations; + } + else + { + allocations = ParseAllocations(allocationsJson); + } + + if (allocations.Count == 0) + { + return JsonSerializer.Serialize(new { success = false, error = "Keine gültigen Allocations" }); + } + + var result = await _lotService.SellLotsAsync(tradeId, allocations, salePriceEurPerUnit); + + var context = _contextAccessor.Current; + if (context != null) + { + context.Session.AssignedCount++; + context.Session.ProcessedCount = context.Session.AssignedCount + context.Session.SkippedCount; + + await context.SendEventAsync(new LotLinkingEventDTO + { + EventType = "assigned", + Message = $"Verkauf zugeordnet: {result.TotalQuantity:F8} verkauft, Gewinn {result.TotalRealizedGain:F2} EUR", + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + } + + return JsonSerializer.Serialize(new + { + success = true, + result.TotalQuantity, + result.TotalRealizedGain, + result.TaxFreeQuantity, + result.TaxableQuantity + }); + } + + private static bool ShouldUseAutoFifo(string? allocationsJson) + { + if (string.IsNullOrWhiteSpace(allocationsJson)) + { + return true; + } + + var trimmed = allocationsJson.Trim(); + return trimmed.Equals(AutoFifoToken, StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("AUTO", StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("FIFO", StringComparison.OrdinalIgnoreCase); + } + + private async Task BuildAutoFifoAllocationsForSellAsync(int tradeId) + { + var trade = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == tradeId); + if (trade == null || trade.TradeType != TradeType.Sell) + { + return new AutoFifoResult(); + } + + var requiredQuantity = trade.Quantity; + if (requiredQuantity <= 0) + { + return new AutoFifoResult(); + } + + return await BuildAutoFifoAllocationsAsync(trade.WalletId, trade.Symbol, requiredQuantity, trade.DateTime); + } + + private async Task BuildAutoFifoAllocationsAsync( + int walletId, + string symbol, + decimal requiredQuantity, + DateTimeOffset maxAcquisitionDate) + { + var lots = await _lotService.GetAvailableLotsAsync(walletId, symbol); + var eligibleLots = lots + .Where(l => l.AcquisitionDate <= maxAcquisitionDate) + .OrderBy(l => l.AcquisitionDate) + .ToList(); + + var allocations = new List(); + var remaining = requiredQuantity; + + foreach (var lot in eligibleLots) + { + if (remaining <= 0) + { + break; + } + + var toAllocate = Math.Min(lot.RemainingQuantity, remaining); + if (toAllocate <= 0) + { + continue; + } + + allocations.Add(new LotAllocation { LotId = lot.Id, Quantity = toAllocate }); + remaining -= toAllocate; + } + + return new AutoFifoResult + { + Allocations = allocations, + MissingQuantity = remaining > 0 ? remaining : 0m + }; + } + + private static List ParseAllocations(string json) + { + var parsed = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }) ?? new List(); + return parsed + .Where(a => a.LotId > 0 && a.Quantity > 0) + .Select(a => new LotAllocation { LotId = a.LotId, Quantity = a.Quantity }) + .ToList(); + } + + private sealed record AllocationInput(int LotId, decimal Quantity); + + private sealed class AutoFifoResult + { + public List Allocations { get; init; } = new(); + public decimal MissingQuantity { get; init; } + } +} diff --git a/src/CryptoTracker/Agent/Tools/SkipLotAssignmentTool.cs b/src/CryptoTracker/Agent/Tools/SkipLotAssignmentTool.cs new file mode 100644 index 0000000..8eda2d7 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/SkipLotAssignmentTool.cs @@ -0,0 +1,52 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Shared; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool um eine Lot-Zuordnung explizit zu überspringen (Session-Logik). +/// +public sealed class SkipLotAssignmentTool : IAgentTool +{ + private readonly ILotLinkingAgentContextAccessor _contextAccessor; + + public SkipLotAssignmentTool(ILotLinkingAgentContextAccessor contextAccessor) + { + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => SkipAssignmentAsync; + public string GetToolName() => "skip_lot_assignment"; + public string GetToolDescription() => """ + Überspringt eine Lot-Zuordnung in der aktuellen Session (keine DB-Änderung). + Parameter: + - assignmentId: ID des Eintrags + - reason: Kurzbegründung + """; + + private async Task SkipAssignmentAsync( + [Description("Assignment ID")] int assignmentId, + [Description("Begründung")] string reason) + { + var context = _contextAccessor.Current; + if (context == null) + { + return JsonSerializer.Serialize(new { success = false, error = "Kein aktiver Session-Kontext" }); + } + + context.Session.SkippedCount++; + context.Session.ProcessedCount = context.Session.AssignedCount + context.Session.SkippedCount; + + await context.SendEventAsync(new LotLinkingEventDTO + { + EventType = "skipped", + Message = $"Übersprungen: #{assignmentId} - {reason}", + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + + return JsonSerializer.Serialize(new { success = true }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/SkipTransactionTool.cs b/src/CryptoTracker/Agent/Tools/SkipTransactionTool.cs new file mode 100644 index 0000000..de484af --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/SkipTransactionTool.cs @@ -0,0 +1,54 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Shared; +using System.ComponentModel; +using System.Text.Json; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool um eine Transaktion explizit zu überspringen (Session-Logik). +/// +public sealed class SkipTransactionTool : IAgentTool +{ + private readonly ILinkingAgentContextAccessor _contextAccessor; + + public SkipTransactionTool(ILinkingAgentContextAccessor contextAccessor) + { + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => SkipTransactionAsync; + public string GetToolName() => "skip_transaction"; + public string GetToolDescription() => """ + Überspringt eine Transaktion in der aktuellen Session (keine DB-Änderung). + Parameter: + - transactionId: ID der Transaktion + - reason: Kurzbegründung warum übersprungen + """; + + private async Task SkipTransactionAsync( + [Description("Transaktions-ID")] int transactionId, + [Description("Begründung")] string reason) + { + var context = _contextAccessor.Current; + if (context == null) + { + return JsonSerializer.Serialize(new { success = false, error = "Kein aktiver Session-Kontext" }); + } + + context.Session.SkippedCount++; + context.Session.ProcessedCount = context.Session.LinkedCount + + context.Session.MarkedExternalCount + + context.Session.SkippedCount; + + await context.SendEventAsync(new LinkingEventDTO + { + EventType = "skipped", + Message = $"Übersprungen: #{transactionId} - {reason}", + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + + return JsonSerializer.Serialize(new { success = true }); + } +} diff --git a/src/CryptoTracker/Agent/Tools/TransferLotsTool.cs b/src/CryptoTracker/Agent/Tools/TransferLotsTool.cs new file mode 100644 index 0000000..bb57d07 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/TransferLotsTool.cs @@ -0,0 +1,198 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Services; +using CryptoTracker.Shared; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool um Lots bei einem Transfer zuzuordnen. +/// +public sealed class TransferLotsTool : IAgentTool +{ + private const string AutoFifoToken = "AUTO_FIFO"; + private readonly LotService _lotService; + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILotLinkingAgentContextAccessor _contextAccessor; + + public TransferLotsTool( + LotService lotService, + CryptoTrackerDbContext dbContext, + ILotLinkingAgentContextAccessor contextAccessor) + { + _lotService = lotService; + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => TransferLotsAsync; + public string GetToolName() => "transfer_lots"; + public string GetToolDescription() => """ + Ordnet Lots bei einem Transfer zu. + Parameter: + - sendTransactionId: ID der Send-Transaktion + - receiveTransactionId: ID der Receive-Transaktion + - allocationsJson: JSON-Array mit { lotId, quantity } oder leer/"AUTO_FIFO" für FIFO + """; + + private async Task TransferLotsAsync( + [Description("Send-Transaktion ID")] int sendTransactionId, + [Description("Receive-Transaktion ID")] int receiveTransactionId, + [Description("JSON-Array {lotId, quantity}")] string allocationsJson) + { + List allocations; + if (ShouldUseAutoFifo(allocationsJson)) + { + var autoResult = await BuildAutoFifoAllocationsForTransferAsync(sendTransactionId, receiveTransactionId); + if (autoResult.MissingQuantity > 0) + { + return JsonSerializer.Serialize(new + { + success = false, + error = $"FIFO unvollständig: es fehlen {autoResult.MissingQuantity:F8}" + }); + } + + allocations = autoResult.Allocations; + } + else + { + allocations = ParseAllocations(allocationsJson); + } + + if (allocations.Count == 0) + { + return JsonSerializer.Serialize(new { success = false, error = "Keine gültigen Allocations" }); + } + + var resultingLots = await _lotService.TransferLotsAsync(sendTransactionId, receiveTransactionId, allocations); + + var context = _contextAccessor.Current; + if (context != null) + { + context.Session.AssignedCount++; + context.Session.CreatedLotsCount += resultingLots.Count; + context.Session.ProcessedCount = context.Session.AssignedCount + context.Session.SkippedCount; + + await context.SendEventAsync(new LotLinkingEventDTO + { + EventType = "assigned", + Message = $"Transfer: {resultingLots.Count} Lot(s) erstellt und zugeordnet", + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + } + + return JsonSerializer.Serialize(new + { + success = true, + createdLots = resultingLots.Count + }); + } + + private static bool ShouldUseAutoFifo(string? allocationsJson) + { + if (string.IsNullOrWhiteSpace(allocationsJson)) + { + return true; + } + + var trimmed = allocationsJson.Trim(); + return trimmed.Equals(AutoFifoToken, StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("AUTO", StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("FIFO", StringComparison.OrdinalIgnoreCase); + } + + private async Task BuildAutoFifoAllocationsForTransferAsync( + int sendTransactionId, + int receiveTransactionId) + { + var send = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == sendTransactionId); + if (send == null) + { + return new AutoFifoResult(); + } + + var receive = await _dbContext.CryptoTransactions + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == receiveTransactionId); + if (receive == null) + { + return new AutoFifoResult(); + } + + var requiredQuantity = receive.QuantityAfterFee > 0 ? receive.QuantityAfterFee : send.QuantityAfterFee; + if (requiredQuantity <= 0) + { + return new AutoFifoResult(); + } + + var maxDate = receive.DateTime; + return await BuildAutoFifoAllocationsAsync(send.WalletId, send.Symbol, requiredQuantity, maxDate); + } + + private async Task BuildAutoFifoAllocationsAsync( + int walletId, + string symbol, + decimal requiredQuantity, + DateTimeOffset maxAcquisitionDate) + { + var lots = await _lotService.GetAvailableLotsAsync(walletId, symbol); + var eligibleLots = lots + .Where(l => l.AcquisitionDate <= maxAcquisitionDate) + .OrderBy(l => l.AcquisitionDate) + .ToList(); + + var allocations = new List(); + var remaining = requiredQuantity; + + foreach (var lot in eligibleLots) + { + if (remaining <= 0) + { + break; + } + + var toAllocate = Math.Min(lot.RemainingQuantity, remaining); + if (toAllocate <= 0) + { + continue; + } + + allocations.Add(new LotAllocation { LotId = lot.Id, Quantity = toAllocate }); + remaining -= toAllocate; + } + + return new AutoFifoResult + { + Allocations = allocations, + MissingQuantity = remaining > 0 ? remaining : 0m + }; + } + + private sealed class AutoFifoResult + { + public List Allocations { get; init; } = new(); + public decimal MissingQuantity { get; init; } + } + + private static List ParseAllocations(string json) + { + var parsed = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }) ?? new List(); + return parsed + .Where(a => a.LotId > 0 && a.Quantity > 0) + .Select(a => new LotAllocation { LotId = a.LotId, Quantity = a.Quantity }) + .ToList(); + } + + private sealed record AllocationInput(int LotId, decimal Quantity); +} diff --git a/src/CryptoTracker/Agent/Tools/TransformSwapLotsTool.cs b/src/CryptoTracker/Agent/Tools/TransformSwapLotsTool.cs new file mode 100644 index 0000000..e7f8fc6 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/TransformSwapLotsTool.cs @@ -0,0 +1,211 @@ +using CryptoTracker.Agent.Common; +using CryptoTracker.Entities; +using CryptoTracker.Services; +using CryptoTracker.Shared; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoTracker.Agent.Tools; + +/// +/// Tool für Crypto-zu-Crypto Swap: Quell-Lots zuweisen und Ziel-Lot erzeugen. +/// +public sealed class TransformSwapLotsTool : IAgentTool +{ + private const string AutoFifoToken = "AUTO_FIFO"; + private readonly LotService _lotService; + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILotLinkingAgentContextAccessor _contextAccessor; + + public TransformSwapLotsTool( + LotService lotService, + CryptoTrackerDbContext dbContext, + ILotLinkingAgentContextAccessor contextAccessor) + { + _lotService = lotService; + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + public Delegate GetToolRunner() => TransformSwapAsync; + public string GetToolName() => "transform_swap_lots"; + public string GetToolDescription() => """ + Ordnet Lots bei einem Crypto-zu-Crypto Swap zu und erstellt das Ziel-Lot. + Parameter: + - sellTradeId: Sell-Trade ID (gibt Crypto ab) + - buyTradeId: Buy-Trade ID (erhält Crypto) + - resultingQuantity: Menge der erhaltenen Coins (nach Gebühren) + - allocationsJson: JSON-Array mit { lotId, quantity } oder leer/"AUTO_FIFO" für FIFO + """; + + private async Task TransformSwapAsync( + [Description("Sell-Trade ID")] int sellTradeId, + [Description("Buy-Trade ID")] int buyTradeId, + [Description("Erhaltene Menge (nach Gebühren)")] decimal resultingQuantity, + [Description("JSON-Array {lotId, quantity}")] string allocationsJson) + { + List allocations; + if (ShouldUseAutoFifo(allocationsJson)) + { + var autoResult = await BuildAutoFifoAllocationsForSwapAsync(sellTradeId); + if (autoResult.MissingQuantity > 0) + { + return JsonSerializer.Serialize(new + { + success = false, + error = $"FIFO unvollständig: es fehlen {autoResult.MissingQuantity:F8}" + }); + } + + allocations = autoResult.Allocations; + } + else + { + allocations = ParseAllocations(allocationsJson); + } + + if (allocations.Count == 0) + { + return JsonSerializer.Serialize(new { success = false, error = "Keine gültigen Allocations" }); + } + + var lot = await _lotService.TransformLotsViaSwapAsync( + sellTradeId, + buyTradeId, + allocations, + resultingQuantity); + + var context = _contextAccessor.Current; + if (context != null) + { + context.Session.AssignedCount++; + context.Session.CreatedLotsCount++; + context.Session.ProcessedCount = context.Session.AssignedCount + context.Session.SkippedCount; + + await context.SendEventAsync(new LotLinkingEventDTO + { + EventType = "lot_created", + Message = $"Swap: Neues Lot #{lot.Id} erstellt ({lot.Symbol} {lot.OriginalQuantity:F8})", + CreatedLot = MapToDto(lot), + ProcessedCount = context.Session.ProcessedCount, + TotalCount = context.Session.TotalCount + }); + } + + return JsonSerializer.Serialize(new { success = true, lotId = lot.Id }); + } + + private static bool ShouldUseAutoFifo(string? allocationsJson) + { + if (string.IsNullOrWhiteSpace(allocationsJson)) + { + return true; + } + + var trimmed = allocationsJson.Trim(); + return trimmed.Equals(AutoFifoToken, StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("AUTO", StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("FIFO", StringComparison.OrdinalIgnoreCase); + } + + private async Task BuildAutoFifoAllocationsForSwapAsync(int sellTradeId) + { + var trade = await _dbContext.CryptoTrades + .Include(t => t.Wallet) + .FirstOrDefaultAsync(t => t.Id == sellTradeId); + if (trade == null || trade.TradeType != TradeType.Sell) + { + return new AutoFifoResult(); + } + + var requiredQuantity = trade.Quantity; + if (requiredQuantity <= 0) + { + return new AutoFifoResult(); + } + + return await BuildAutoFifoAllocationsAsync(trade.WalletId, trade.Symbol, requiredQuantity, trade.DateTime); + } + + private async Task BuildAutoFifoAllocationsAsync( + int walletId, + string symbol, + decimal requiredQuantity, + DateTimeOffset maxAcquisitionDate) + { + var lots = await _lotService.GetAvailableLotsAsync(walletId, symbol); + var eligibleLots = lots + .Where(l => l.AcquisitionDate <= maxAcquisitionDate) + .OrderBy(l => l.AcquisitionDate) + .ToList(); + + var allocations = new List(); + var remaining = requiredQuantity; + + foreach (var lot in eligibleLots) + { + if (remaining <= 0) + { + break; + } + + var toAllocate = Math.Min(lot.RemainingQuantity, remaining); + if (toAllocate <= 0) + { + continue; + } + + allocations.Add(new LotAllocation { LotId = lot.Id, Quantity = toAllocate }); + remaining -= toAllocate; + } + + return new AutoFifoResult + { + Allocations = allocations, + MissingQuantity = remaining > 0 ? remaining : 0m + }; + } + + private static List ParseAllocations(string json) + { + var parsed = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }) ?? new List(); + return parsed + .Where(a => a.LotId > 0 && a.Quantity > 0) + .Select(a => new LotAllocation { LotId = a.LotId, Quantity = a.Quantity }) + .ToList(); + } + + private static LotDTO MapToDto(AssetLot lot) => new( + lot.Id, + lot.Symbol, + lot.CurrentWalletId, + lot.CurrentWallet?.Name ?? string.Empty, + lot.RemainingQuantity, + lot.OriginalQuantity, + lot.AcquisitionDate, + lot.AcquisitionPriceEur, + lot.TotalAcquisitionCostEur, + lot.AcquisitionType.ToString(), + lot.IsAltbestand, + lot.IsFullyConsumed, + lot.Note, + lot.ParentLotId, + lot.SourceTradeId, + lot.SourceTransactionId, + lot.IsFlowComplete, + lot.FlowIncompleteReason); + + private sealed record AllocationInput(int LotId, decimal Quantity); + + private sealed class AutoFifoResult + { + public List Allocations { get; init; } = new(); + public decimal MissingQuantity { get; init; } + } +} diff --git a/src/CryptoTracker/Components/App.razor b/src/CryptoTracker/Components/App.razor index 4882368..91fedd7 100644 --- a/src/CryptoTracker/Components/App.razor +++ b/src/CryptoTracker/Components/App.razor @@ -14,6 +14,7 @@ + diff --git a/src/CryptoTracker/Controllers/LotsController.cs b/src/CryptoTracker/Controllers/LotsController.cs index 0e1d933..0e2ab86 100644 --- a/src/CryptoTracker/Controllers/LotsController.cs +++ b/src/CryptoTracker/Controllers/LotsController.cs @@ -230,7 +230,10 @@ public async Task> GetPendingLotAssignments() t.WalletId, t.TransactionType.ToString(), t.OppositeWallet?.Name, - t.Comment))); + t.Comment) + { + OppositeTransactionId = t.OppositeTransactionId + })); result.AddRange(trades.Select(t => new PendingLotAssignmentDTO( "Trade", @@ -242,7 +245,11 @@ public async Task> GetPendingLotAssignments() t.WalletId, t.TradeType.ToString(), null, - t.Comment))); + t.Comment) + { + OppositeTradeId = t.OppositeTradeId, + OppositeSymbol = t.OppositeSymbol + })); return result.OrderBy(p => p.DateTime).ToList(); } @@ -414,15 +421,20 @@ public async Task GetLotLinkingStatistics() .CountAsync(t => t.TransactionType == TransactionType.Receive && !t.LotAssignmentConfirmed); var pendingSell = await _dbContext.CryptoTrades .CountAsync(t => t.TradeType == TradeType.Sell && !t.LotAssignmentConfirmed); + var pendingBuy = await _dbContext.CryptoTrades + .CountAsync(t => t.TradeType == TradeType.Buy + && t.ResultingLotId == null + && FiatSymbols.ForQuery.Contains(t.OppositeSymbol)); var completedTx = await _dbContext.CryptoTransactions .CountAsync(t => t.LotAssignmentConfirmed); var totalLots = await _dbContext.AssetLots.CountAsync(); return new LotLinkingStatisticsDTO { - TotalPendingAssignments = pendingReceive + pendingSell, + TotalPendingAssignments = pendingReceive + pendingSell + pendingBuy, PendingReceiveTransactions = pendingReceive, PendingSellTrades = pendingSell, + PendingBuyTrades = pendingBuy, CompletedAssignments = completedTx, LotsCreated = totalLots }; diff --git a/src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.Designer.cs b/src/CryptoTracker/Migrations/20260201160654_TransactionLinkingAndLots.Designer.cs similarity index 99% rename from src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.Designer.cs rename to src/CryptoTracker/Migrations/20260201160654_TransactionLinkingAndLots.Designer.cs index b3e8351..2c58942 100644 --- a/src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.Designer.cs +++ b/src/CryptoTracker/Migrations/20260201160654_TransactionLinkingAndLots.Designer.cs @@ -12,7 +12,7 @@ namespace CryptoTracker.Migrations { [DbContext(typeof(CryptoTrackerDbContext))] - [Migration("20260201135201_TransactionLinkingAndLots")] + [Migration("20260201160654_TransactionLinkingAndLots")] partial class TransactionLinkingAndLots { /// diff --git a/src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.cs b/src/CryptoTracker/Migrations/20260201160654_TransactionLinkingAndLots.cs similarity index 100% rename from src/CryptoTracker/Migrations/20260201135201_TransactionLinkingAndLots.cs rename to src/CryptoTracker/Migrations/20260201160654_TransactionLinkingAndLots.cs diff --git a/src/CryptoTracker/Services/FiatSymbols.cs b/src/CryptoTracker/Services/FiatSymbols.cs new file mode 100644 index 0000000..c16e5dc --- /dev/null +++ b/src/CryptoTracker/Services/FiatSymbols.cs @@ -0,0 +1,23 @@ +using System.Linq; + +namespace CryptoTracker.Services; + +internal static class FiatSymbols +{ + // Enthält auch Lowercase-Varianten für EF Core (case-sensitiv). + public static readonly string[] ForQuery = + { + "EUR", "USD", "CHF", "GBP", "ZEUR", "ZUSD", + "eur", "usd", "chf", "gbp", "zeur", "zusd" + }; + + public static bool IsFiat(string symbol) + { + if (string.IsNullOrWhiteSpace(symbol)) + { + return false; + } + + return ForQuery.Contains(symbol, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/CryptoTracker/Services/LotService.cs b/src/CryptoTracker/Services/LotService.cs index 7d9ed4d..fddb819 100644 --- a/src/CryptoTracker/Services/LotService.cs +++ b/src/CryptoTracker/Services/LotService.cs @@ -604,24 +604,18 @@ public async Task> SuggestFifoAllocationAsync( #region Lot-Generierung aus bestehenden Daten - /// - /// Fiat-Symbole für EF Core Query (muss als statisches Array definiert sein). - /// Enthält auch Lowercase-Varianten für Case-Insensitive-Vergleich. - /// - private static readonly string[] FiatSymbolsForQuery = { "EUR", "USD", "CHF", "GBP", "ZEUR", "ZUSD", "eur", "usd", "chf", "gbp", "zeur", "zusd" }; - /// /// Generiert Lots aus bestehenden Buy-Trades die noch kein Lot haben. /// public async Task GenerateLotsFromExistingTradesAsync() { - // Hinweis: FiatSymbolsForQuery muss inline verwendet werden, da EF Core keine Methodenaufrufe übersetzen kann + // Hinweis: FiatSymbols.ForQuery muss inline verwendet werden, da EF Core keine Methodenaufrufe übersetzen kann // Contains-Vergleich ist case-sensitive, daher enthält das Array beide Varianten var tradesWithoutLots = await _dbContext.CryptoTrades .Include(t => t.Wallet) .Where(t => t.TradeType == TradeType.Buy && t.ResultingLotId == null - && FiatSymbolsForQuery.Contains(t.OppositeSymbol)) + && FiatSymbols.ForQuery.Contains(t.OppositeSymbol)) .OrderBy(t => t.DateTime) .ToListAsync(); @@ -658,11 +652,6 @@ public async Task GenerateLotsFromExistingTradesAsync() return count; } - private static bool IsFiatSymbol(string symbol) - { - return FiatSymbolsForQuery.Contains(symbol.ToUpperInvariant()); - } - #endregion #region Transaktionen ohne Lot-Zuordnung diff --git a/src/CryptoTracker/Startup.cs b/src/CryptoTracker/Startup.cs index 6fde7c4..c77f048 100644 --- a/src/CryptoTracker/Startup.cs +++ b/src/CryptoTracker/Startup.cs @@ -117,18 +117,38 @@ public void ConfigureServices(IServiceCollection services) // Agent Builder services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); // Agent Tools services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Agent Definitions services.AddScoped(); + services.AddScoped(); // Agent Services services.AddScoped(); diff --git a/src/CryptoTracker/wwwroot/app.css b/src/CryptoTracker/wwwroot/app.css index 9b97a97..87b7bdb 100644 --- a/src/CryptoTracker/wwwroot/app.css +++ b/src/CryptoTracker/wwwroot/app.css @@ -536,10 +536,16 @@ h1, h2, h3, .app-title { .details-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + grid-template-columns: 1fr; gap: 0.6rem 1rem; } +@media (min-width: 900px) { + .details-grid { + grid-template-columns: repeat(2, minmax(240px, 1fr)); + } +} + .details-item label { display: block; font-size: 0.75rem; @@ -1197,6 +1203,13 @@ h1, h2, h3, .app-title { overflow-y: auto; } +.modal-card.details-modal { + width: min(860px, 92vw); + max-width: 860px; + max-height: 85vh; + overflow: auto; +} + .modal-header { display: flex; justify-content: space-between; @@ -1964,15 +1977,26 @@ h1, h2, h3, .app-title { .wizard-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; + width: 100vw; + height: 100vh; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; z-index: 2000; + overscroll-behavior: contain; +} + +html.overlay-open, +body.overlay-open { + overflow: hidden; +} + +body.overlay-open .rz-body, +body.overlay-open .rz-layout { + overflow: hidden; + transform: none !important; } .wizard-container { diff --git a/src/CryptoTracker/wwwroot/app.js b/src/CryptoTracker/wwwroot/app.js new file mode 100644 index 0000000..24c5c09 --- /dev/null +++ b/src/CryptoTracker/wwwroot/app.js @@ -0,0 +1,58 @@ +window.cryptoTracker = window.cryptoTracker || {}; +window.cryptoTracker._manualOverlayCount = window.cryptoTracker._manualOverlayCount || 0; + +window.cryptoTracker.updateOverlayState = function () { + if (!document || !document.body || !document.documentElement) { + return; + } + + const hasOverlay = !!document.querySelector(".wizard-overlay, .modal-overlay"); + const isOpen = hasOverlay || window.cryptoTracker._manualOverlayCount > 0; + + document.body.classList.toggle("overlay-open", isOpen); + document.documentElement.classList.toggle("overlay-open", isOpen); +}; + +window.cryptoTracker.setOverlayOpen = function (isOpen) { + if (!document) { + return; + } + + if (isOpen) { + window.cryptoTracker._manualOverlayCount += 1; + } else { + window.cryptoTracker._manualOverlayCount = Math.max(0, window.cryptoTracker._manualOverlayCount - 1); + } + + window.cryptoTracker.updateOverlayState(); +}; + +window.cryptoTracker.setWizardOpen = function (isOpen) { + window.cryptoTracker.setOverlayOpen(isOpen); +}; + +(function initOverlayObserver() { + if (!document) { + return; + } + + const start = function () { + if (window.cryptoTracker._overlayObserver || !document.body) { + return; + } + + const observer = new MutationObserver(function () { + window.cryptoTracker.updateOverlayState(); + }); + + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] }); + window.cryptoTracker._overlayObserver = observer; + window.cryptoTracker.updateOverlayState(); + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start, { once: true }); + } else { + start(); + } +})();