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/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/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/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/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/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/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/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/LotsLinking.razor b/src/CryptoTracker.Client/Pages/LotsLinking.razor new file mode 100644 index 0000000..282a8f7 --- /dev/null +++ b/src/CryptoTracker.Client/Pages/LotsLinking.razor @@ -0,0 +1,432 @@ +@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 +
+} + +@* Lot-Linking Wizard *@ +@if (IsWizardOpen) +{ + +} diff --git a/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs b/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs new file mode 100644 index 0000000..4053322 --- /dev/null +++ b/src/CryptoTracker.Client/Pages/LotsLinking.razor.cs @@ -0,0 +1,393 @@ +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; + + // Wizard State + private bool IsWizardOpen = false; + private LotLinkingStatisticsDTO? LotLinkingStatistics; + + // 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; + } + + // 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; + 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 + { + var request = new CreateManualLotRequest( + Symbol: CreateLotForAssignment.Symbol, + WalletId: CreateLotForAssignment.WalletId, + 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..acb81fa --- /dev/null +++ b/src/CryptoTracker.Client/Pages/TransactionLinking.razor @@ -0,0 +1,367 @@ +@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)) + { + + } +
+ } +} + +@* 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..3e7c513 --- /dev/null +++ b/src/CryptoTracker.Client/Pages/TransactionLinking.razor.cs @@ -0,0 +1,326 @@ +using CryptoTracker.Shared; +using Microsoft.AspNetCore.Components; + +namespace CryptoTracker.Client.Pages; + +public partial class TransactionLinking +{ + // State + private bool IsLoading = true; + 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; + + // 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 + { + 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; + } + + // Auto-Link + private async Task RunAutoLink() + { + IsAutoLinking = true; + StateHasChanged(); + + try + { + var result = await LinkingApi.RunAutoLinkAsync(); + if (result.Success) + { + SuccessMessage = $"Schnell-Verknüpfung: {result.LinkedCount} verknüpft, {result.MarkedUnlinkedCount} extern, {result.RemainingUnlinkedCount} offen."; + await LoadDataAsync(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Auto-link error: {ex.Message}"); + } + finally + { + IsAutoLinking = false; + StateHasChanged(); + _ = HideSuccessMessageAfterDelay(); + } + } + + private async Task HideSuccessMessageAfterDelay() + { + await Task.Delay(5000); + SuccessMessage = null; + await 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 + { + 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: {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..]}"; + } +} diff --git a/src/CryptoTracker.Client/Pages/Transaktionen.razor b/src/CryptoTracker.Client/Pages/Transaktionen.razor index ef871be..15c09e1 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 @@ -78,72 +79,66 @@ @FormatHelper.FormatAmount(row.Amount) - - - - - - - - - - - - - - - - - - - + - + + + + + + + + + + + + + - + @@ -156,6 +151,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 e27e48e..27753e8 100644 --- a/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs +++ b/src/CryptoTracker.Client/Pages/Transaktionen.razor.cs @@ -26,9 +26,23 @@ public partial class Transaktionen private FlowType? SelectedFlowType { get; set; } private int? SelectedFlowId { get; set; } private bool IsToggleHiddenBusy { 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(); @@ -178,8 +192,6 @@ private async Task OpenDetailsAsync(TransactionRowDTO row) IsDetailsLoading = true; DetailsError = null; Details = null; - SelectedFlowType = row.FlowType; - SelectedFlowId = row.FlowId; try { Details = await TransactionsApi.GetTransactionDetailsAsync(row.FlowType, row.FlowId, ShowHidden); @@ -187,6 +199,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) { @@ -200,9 +214,6 @@ private void CloseDetails() IsDetailsOpen = false; Details = null; DetailsError = null; - SelectedFlowType = null; - SelectedFlowId = null; - IsToggleHiddenBusy = false; } private bool CanToggleHidden => CurrentHidden.HasValue && SelectedFlowType.HasValue && SelectedFlowId.HasValue; @@ -309,4 +320,244 @@ private static bool ParseBool(string? value, bool fallback) _ => fallback }; } + + /// + /// 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; + } + + /// + /// 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 + /// + 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; + } + } } diff --git a/src/CryptoTracker.Client/Pages/Wallets.razor.cs b/src/CryptoTracker.Client/Pages/Wallets.razor.cs index 1aec993..44a2d75 100644 --- a/src/CryptoTracker.Client/Pages/Wallets.razor.cs +++ b/src/CryptoTracker.Client/Pages/Wallets.razor.cs @@ -8,6 +8,7 @@ public partial class Wallets private IList WalletsList { get; set; } = new List(); private string EditName { get; set; } = string.Empty; private int EditId { get; set; } + private bool EditIsVirtual { get; set; } private string? ErrorMessage { get; set; } protected override async Task OnInitializedAsync() @@ -19,17 +20,19 @@ private void NewWallet() { EditId = 0; EditName = string.Empty; + EditIsVirtual = false; } private void Edit(WalletInfoDTO wallet) { EditId = wallet.Id; EditName = wallet.Name; + EditIsVirtual = wallet.IsVirtual; } private async Task Save() { - var wallet = new WalletInfoDTO(EditId, EditName); + var wallet = new WalletInfoDTO(EditId, EditName, EditIsVirtual); try { await WalletApi.SaveWalletAsync(wallet); 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/RestClients/WalletRestClient.cs b/src/CryptoTracker.Client/RestClients/WalletRestClient.cs index 51e987e..b59ad99 100644 --- a/src/CryptoTracker.Client/RestClients/WalletRestClient.cs +++ b/src/CryptoTracker.Client/RestClients/WalletRestClient.cs @@ -21,6 +21,9 @@ public async Task> GetWalletsWithSymbolsAsync() public async Task> GetWalletInfosAsync() => await _http.GetFromJsonAsync>("api/Wallet/GetWalletInfos") ?? new List(); + public async Task> GetVirtualWalletInfosAsync() + => await _http.GetFromJsonAsync>("api/Wallet/GetVirtualWalletInfos") ?? new List(); + public async Task SaveWalletAsync(WalletInfoDTO wallet) { var response = await _http.PostAsJsonAsync("api/Wallet/SaveWallet", wallet); 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/ILotsApi.cs b/src/CryptoTracker.Client/Shared/Api/ILotsApi.cs new file mode 100644 index 0000000..cc8c35c --- /dev/null +++ b/src/CryptoTracker.Client/Shared/Api/ILotsApi.cs @@ -0,0 +1,34 @@ +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); + + // Interactive Lot-Linking + Task StartInteractiveLotLinkingSessionAsync(); + Task StopInteractiveLotLinkingSessionAsync(string sessionId); + Task> GetLotLinkingRulesAsync(); + Task DeleteLotLinkingRuleAsync(string ruleId); + Task GetLotLinkingStatisticsAsync(); +} diff --git a/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs b/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs new file mode 100644 index 0000000..73c8534 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/Api/ITransactionLinkingApi.cs @@ -0,0 +1,94 @@ +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); + + // === 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/Api/IWalletApi.cs b/src/CryptoTracker.Client/Shared/Api/IWalletApi.cs index 66c6a76..ba086f8 100644 --- a/src/CryptoTracker.Client/Shared/Api/IWalletApi.cs +++ b/src/CryptoTracker.Client/Shared/Api/IWalletApi.cs @@ -5,6 +5,7 @@ public interface IWalletApi Task> GetWalletsAsync(); Task> GetWalletsWithSymbolsAsync(); Task> GetWalletInfosAsync(); + Task> GetVirtualWalletInfosAsync(); Task SaveWalletAsync(WalletInfoDTO wallet); Task DeleteWalletAsync(int id); } diff --git a/src/CryptoTracker.Client/Shared/LinkingWizard.razor b/src/CryptoTracker.Client/Shared/LinkingWizard.razor new file mode 100644 index 0000000..6ef2717 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LinkingWizard.razor @@ -0,0 +1,641 @@ +@using CryptoTracker.Shared +@using Microsoft.AspNetCore.SignalR.Client +@implements IAsyncDisposable + +
+
+
+

Transaktions-Verknüpfung

+

@GetStatusText()

+ +
+ +
+ @* 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) + { +
+
+ + 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" + } +
+ } +
+
+ @if (CurrentOptions != null) + { + @foreach (var option in CurrentOptions) + { + + } + @* Freitext-Option (Fallback, falls Agent-Option fehlt) *@ + @if (!ShowFreeTextInput && !HasFreeTextOption) + { + + } + } +
+ @if (ShowVirtualWalletSelection) + { +
+ + + @if (IsLoadingVirtualWallets) + { +
+ + Lade virtuelle Wallets... +
+ } + @if (!IsLoadingVirtualWallets && VirtualWallets.Count == 0) + { +
Noch keine virtuellen Wallets vorhanden.
+ } +
oder
+ + +
+ + + +
+
+ } + @* Freitext-Eingabe *@ + @if (ShowFreeTextInput) + { +
+ + +
+ + +
+
+ } +
+ +
+
+ } + + @* Learned Rules Section *@ + @if (LearnedRules.Count > 0) + { +
+
+ @(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) + { +
+
+
+ @Statistics.UnlinkedTransactions + Unverknüpfte Transaktionen +
+
+ @Statistics.LinkedTransactions + Bereits verknüpft +
+
+ @Statistics.IntentionallyUnlinked + Als extern markiert +
+
+
+ } +
+ +
+
+ } + else if (IsCompleted) + { +
+
+

Verknüpfung abgeschlossen!

+
+
+ @LinkedCount + Verknüpft +
+
+ @MarkedExternalCount + Extern markiert +
+ @if (SkippedCount > 0) + { +
+ @SkippedCount + Übersprungen +
+ } +
+
+ } + + @* 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 new file mode 100644 index 0000000..2f05ef8 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LinkingWizard.razor.cs @@ -0,0 +1,593 @@ +using CryptoTracker.Shared; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.JSInterop; + +namespace CryptoTracker.Client.Shared; + +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 IWalletApi WalletApi { get; set; } = default!; + [Inject] private NavigationManager NavigationManager { get; set; } = default!; + [Inject] private IJSRuntime JsRuntime { get; set; } = default!; + + // State + private bool IsStarted = false; + private bool IsConnecting = false; + private bool IsProcessing = false; + private bool IsCompleted = false; + private bool IsAnswering = false; + private string? ErrorMessage; + + // 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; + 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(); + private bool RulesExpanded = false; + + // Event log + private List EventLog = new(); + private ElementReference eventLogElement; + + // Session + 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() + { + // Load learned rules + try + { + var rules = await LinkingApi.GetLearnedRulesAsync(); + LearnedRules = rules.ToList(); + } + catch + { + // Ignore - rules are optional + } + } + + private string GetStatusText() + { + if (!IsStarted) return "Bereit zum Starten"; + if (IsCompleted) return "Abgeschlossen"; + if (CurrentQuestion != null) return "Warte auf Eingabe"; + return $"Verarbeite... ({ProcessedCount}/{TotalCount})"; + } + + private async Task StartInteractiveSession() + { + IsConnecting = true; + ErrorMessage = null; + StateHasChanged(); + + try + { + // 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; + ProcessingMessage = "Analysiere Transaktionen..."; + AddEvent("start", "Interaktive Verknüpfung gestartet"); + } + catch (Exception ex) + { + 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) + { + 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; + ShowVirtualWalletSelection = false; + SelectedVirtualWalletId = null; + NewVirtualWalletName = null; + 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; + + case "info": + AddEvent("info", evt.Message); + break; + } + + 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; + ShowVirtualWalletSelection = false; + SelectedVirtualWalletId = null; + NewVirtualWalletName = null; + IsProcessing = false; + } + + StateHasChanged(); + }); + } + + private void HandleError(string error) + { + InvokeAsync(() => + { + ErrorMessage = error; + AddEvent("error", error); + StateHasChanged(); + }); + } + + private void HandleSessionCompleted(LinkingStatisticsDTO stats) + { + InvokeAsync(() => + { + 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 (string.Equals(option, VirtualWalletOptionLabel, StringComparison.OrdinalIgnoreCase)) + { + ShowVirtualWalletSelection = true; + ShowFreeTextInput = false; + FreeTextInput = null; + await LoadVirtualWalletsAsync(); + StateHasChanged(); + 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; + + 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 + { + // 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; + ShowFreeTextInput = false; + FreeTextInput = null; + ShowVirtualWalletSelection = false; + SelectedVirtualWalletId = null; + NewVirtualWalletName = null; + IsProcessing = true; + ProcessingMessage = "Verarbeite Antwort..."; + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Senden: {ex.Message}"; + } + finally + { + IsAnswering = false; + StateHasChanged(); + } + } + + private async Task RefreshRulesAsync() + { + try + { + var rules = await LinkingApi.GetLearnedRulesAsync(); + LearnedRules = rules.ToList(); + StateHasChanged(); + } + catch + { + // Ignore + } + } + + private async Task DeleteRule(string ruleId) + { + try + { + var success = await LinkingApi.DeleteLearnedRuleAsync(ruleId); + if (success) + { + LearnedRules.RemoveAll(r => r.Id == ruleId); + StateHasChanged(); + } + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Löschen: {ex.Message}"; + StateHasChanged(); + } + } + + private void ToggleRulesExpanded() + { + RulesExpanded = !RulesExpanded; + } + + private void ShowFreeText() + { + ShowFreeTextInput = true; + FreeTextInput = null; + ShowVirtualWalletSelection = false; + SelectedVirtualWalletId = null; + NewVirtualWalletName = null; + } + + private void CancelFreeText() + { + ShowFreeTextInput = false; + FreeTextInput = null; + } + + 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}"); + } + + 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 + { + 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("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" + }; + } + + private async Task Complete() + { + await OnComplete.InvokeAsync(); + } + + public async ValueTask DisposeAsync() + { + if (BodyLockApplied) + { + await SetBodyLockAsync(false); + } + + if (hubConnection != null) + { + if (SessionId != null) + { + try + { + await hubConnection.InvokeAsync("LeaveSession", SessionId); + } + catch + { + // Ignore + } + } + await hubConnection.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; } = ""; + public string Message { get; set; } = ""; + public DateTime Timestamp { get; set; } + + public string Icon => Type switch + { + "linked" => "✓", + "external" => "⊕", + "skipped" => "⏭", + "error" => "⚠", + "rule" => "📝", + "info" => "ℹ", + "start" => "▶", + "stop" => "⏹", + "complete" => "🎉", + _ => "•" + }; + + public string CssClass => Type switch + { + "linked" => "linked", + "external" => "external", + "skipped" => "skipped", + "error" => "error", + "rule" => "rule", + "info" => "info", + _ => "" + }; + } +} diff --git a/src/CryptoTracker.Client/Shared/LotDTOs.cs b/src/CryptoTracker.Client/Shared/LotDTOs.cs new file mode 100644 index 0000000..8ba0771 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LotDTOs.cs @@ -0,0 +1,267 @@ +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, + bool IsFlowComplete = true, + string? FlowIncompleteReason = null); + +/// +/// 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, + int WalletId, + string? Direction, // "Receive" für Transactions, "Sell" für Trades + string? OppositeWalletName, + 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. +/// +public record GenerateLotsRequest( + bool FromTrades = true, + bool FromTransactions = false); + +/// +/// Ergebnis der Lot-Generierung. +/// +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); + +// ============================================ +// DTOs für interaktives Lot-Linking +// ============================================ + +/// +/// Statistiken für Lot-Zuordnungen. +/// +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; } +} + +/// +/// Session-State für interaktives Lot-Linking. +/// +public record InteractiveLotLinkingSessionDTO +{ + public string SessionId { get; init; } = ""; + public bool IsActive { get; init; } + public int ProcessedCount { get; init; } + public int TotalCount { get; init; } + public int AssignedCount { get; init; } + public int CreatedLotsCount { get; init; } + public int SkippedCount { get; init; } + public string? CurrentQuestionId { get; init; } + public string? CurrentQuestion { get; init; } + public PendingLotAssignmentDTO? CurrentAssignment { get; init; } + public IList? CurrentOptions { get; init; } + public IList? CurrentLotOptions { get; init; } +} + +/// +/// Live-Event vom Lot-Linking Agent. +/// +public record LotLinkingEventDTO +{ + public string EventType { get; init; } = ""; // "assigned", "lot_created", "question", "progress", "error", "rule_learned" + public string Message { get; init; } = ""; + public PendingLotAssignmentDTO? Assignment { get; init; } + public LotDTO? CreatedLot { get; init; } + public string? QuestionId { get; init; } + public IList? Options { get; init; } + public IList? LotOptions { get; init; } + public int ProcessedCount { get; init; } + public int TotalCount { get; init; } +} + +/// +/// Option für Lot-Auswahl bei Fragen. +/// +public record LotOptionDTO +{ + public int LotId { get; init; } + public string DisplayText { get; init; } = ""; + public decimal AvailableQuantity { get; init; } + public DateTimeOffset AcquisitionDate { get; init; } + public decimal AcquisitionPriceEur { get; init; } + public bool IsAltbestand { get; init; } + public string WalletName { get; init; } = ""; +} + +/// +/// Antwort vom User auf Lot-Linking Frage. +/// +public record LotLinkingUserResponseDTO +{ + public string QuestionId { get; init; } = ""; + public string Response { get; init; } = ""; + public bool ShouldRemember { get; init; } = true; + public IList? LotAllocations { get; init; } + public string? CustomText { get; init; } + // Für manuelle Lot-Erstellung + public string? AcquisitionType { get; init; } + public decimal? AcquisitionPriceEur { get; init; } + public string? Note { get; init; } +} + +/// +/// Gelernte Regel für Lot-Zuordnung. +/// +public record LotLinkingRuleDTO +{ + public string Id { get; init; } = ""; + public string RuleType { get; init; } = ""; // "comment_pattern", "symbol_pattern", "wallet_pattern" + public string Pattern { get; init; } = ""; + public string Action { get; init; } = ""; // "create_lot_airdrop", "create_lot_staking", "create_lot_mining", "fifo", "skip" + public string Description { get; init; } = ""; + public int TimesApplied { get; init; } + public DateTimeOffset CreatedAt { get; init; } +} 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/LotLinkingWizard.razor b/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor new file mode 100644 index 0000000..07fad29 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor @@ -0,0 +1,673 @@ +@using CryptoTracker.Shared +@using CryptoTracker.Client.Common +@using Microsoft.AspNetCore.SignalR.Client +@implements IAsyncDisposable + +
+
+
+

Lot-Zuordnung

+

@GetStatusText()

+ +
+ +
+ @* Progress Section *@ +
+
+
+
+
+ @ProcessedCount / @TotalCount Einträge + @if (AssignedCount > 0 || CreatedLotsCount > 0 || SkippedCount > 0) + { + + Lots: @CreatedLotsCount erstellt | + @AssignedCount zugeordnet | + @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) + { +
+
+ ? + Agent fragt: +
+
+
@((MarkupString)CurrentQuestion.Replace("\n", "
"))
+ @if (CurrentAssignment != null) + { +
+ + @CurrentAssignment.Direction + + @CurrentAssignment.Symbol + @FormatHelper.FormatAmount(CurrentAssignment.Quantity) + @CurrentAssignment.WalletName + @FormatHelper.FormatUtc(CurrentAssignment.DateTime) +
+ } +
+ + @* Lot-Allocation *@ + @if (CurrentLotOptions != null && CurrentLotOptions.Count > 0) + { +
+
+ 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) + + +
+ } +
+
+ +
+
+ } + + @* Option Buttons *@ +
+ @if (CurrentOptions != null) + { + @foreach (var option in CurrentOptions) + { + + } + @* Freitext-Option *@ + @if (!ShowFreeTextInput && !HasFreeTextOption) + { + + } + } +
+ + @* Freitext-Eingabe *@ + @if (ShowFreeTextInput) + { +
+ + +
+ + +
+
+ } + + @* Remember Checkbox *@ +
+ +
+
+ } + + @* Learned Rules Section *@ + @if (LearnedRules.Count > 0) + { +
+
+ @(RulesExpanded ? "v" : ">") Gelernte Regeln (@LearnedRules.Count) +
+ @if (RulesExpanded) + { +
+ @foreach (var rule in LearnedRules) + { +
+ "@rule.Pattern" + -> + @rule.Description + (@rule.TimesApplied x angewendet) + +
+ } +
+ } +
+ } + + @* Start / Completion Section *@ + @if (!IsStarted) + { +
+
+
+

Interaktive Lot-Zuordnung

+

+ 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) + { +
+
+
+ @Statistics.PendingReceiveTransactions + Receive ohne Lot +
+
+ @Statistics.PendingBuyTrades + Fiat-Käufe ohne Lot +
+
+ @Statistics.PendingSellTrades + Verkäufe ohne Zuordnung +
+
+ @Statistics.LotsCreated + Lots vorhanden +
+
+
+ } +
+ +
+
+ } + else if (IsCompleted) + { +
+
OK
+

Lot-Zuordnung abgeschlossen!

+
+
+ @CreatedLotsCount + Lots erstellt +
+
+ @AssignedCount + Zugeordnet +
+ @if (SkippedCount > 0) + { +
+ @SkippedCount + Übersprungen +
+ } +
+
+ } + + @* Error Display *@ + @if (!string.IsNullOrEmpty(ErrorMessage)) + { +
+ ! @ErrorMessage +
+ } +
+ + +
+
+ + diff --git a/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor.cs b/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor.cs new file mode 100644 index 0000000..f11ddd8 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LotLinkingWizard.razor.cs @@ -0,0 +1,640 @@ +using CryptoTracker.Shared; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.JSInterop; + +namespace CryptoTracker.Client.Shared; + +public partial class LotLinkingWizard : IAsyncDisposable +{ + [Parameter] public EventCallback OnComplete { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public LotLinkingStatisticsDTO? Statistics { get; set; } + + [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; + private bool IsConnecting = false; + private bool IsProcessing = false; + private bool IsCompleted = false; + private bool IsAnswering = false; + private string? ErrorMessage; + + // Progress + private int ProcessedCount = 0; + private int TotalCount = 0; + private int AssignedCount = 0; + private int CreatedLotsCount = 0; + private int SkippedCount = 0; + private string ProcessingMessage = "Starte..."; + + // Question state + private string? CurrentQuestionId; + private string? CurrentQuestion; + private PendingLotAssignmentDTO? CurrentAssignment; + private IList? CurrentOptions; + private bool ShouldRemember = true; + private IList? CurrentLotOptions; + private List AllocationInputs = new(); + private bool ShowFreeTextInput = false; + private string? FreeTextInput; + + // 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 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() + { + // Load learned rules + try + { + var rules = await LotsApi.GetLotLinkingRulesAsync(); + LearnedRules = rules.ToList(); + } + catch + { + // Ignore - rules are optional + } + } + + private string GetStatusText() + { + if (!IsStarted) return "Bereit zum Starten"; + if (IsCompleted) return "Abgeschlossen"; + if (CurrentQuestion != null) return "Warte auf Eingabe"; + return $"Verarbeite... ({ProcessedCount}/{TotalCount})"; + } + + private async Task StartInteractiveSession() + { + IsConnecting = true; + ErrorMessage = null; + StateHasChanged(); + + try + { + // Start session via API + var session = await LotsApi.StartInteractiveLotLinkingSessionAsync(); + 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("OnLotLinkingEvent", HandleLotLinkingEvent); + hubConnection.On("OnLotLinkingSessionUpdate", HandleSessionUpdate); + hubConnection.On("OnLotLinkingError", HandleError); + hubConnection.On("OnLotLinkingSessionCompleted", HandleSessionCompleted); + + // Connect and join session + await hubConnection.StartAsync(); + await hubConnection.InvokeAsync("JoinSession", SessionId); + + IsStarted = true; + IsProcessing = true; + ProcessingMessage = "Analysiere ausstehende Zuordnungen..."; + AddEvent("start", "Interaktive Lot-Zuordnung gestartet"); + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Starten: {ex.Message}"; + } + finally + { + IsConnecting = false; + StateHasChanged(); + } + } + + private async Task StopSession() + { + if (SessionId == null) return; + + try + { + await LotsApi.StopInteractiveLotLinkingSessionAsync(SessionId); + AddEvent("stop", "Session abgebrochen"); + IsCompleted = true; + IsProcessing = false; + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Stoppen: {ex.Message}"; + } + + StateHasChanged(); + } + + private void HandleLotLinkingEvent(LotLinkingEventDTO evt) + { + InvokeAsync(() => + { + switch (evt.EventType) + { + case "assigned": + AssignedCount++; + ProcessedCount = evt.ProcessedCount; + AddEvent("assigned", evt.Message); + break; + + case "lot_created": + CreatedLotsCount++; + ProcessedCount = evt.ProcessedCount; + AddEvent("lot_created", evt.Message); + break; + + case "skipped": + SkippedCount++; + ProcessedCount = evt.ProcessedCount; + AddEvent("skipped", evt.Message); + break; + + case "question": + CurrentQuestionId = evt.QuestionId; + CurrentQuestion = evt.Message; + CurrentAssignment = evt.Assignment; + CurrentOptions = evt.Options; + CurrentLotOptions = evt.LotOptions; + InitializeLotAllocations(evt.LotOptions); + ShowFreeTextInput = false; + FreeTextInput = null; + IsProcessing = false; + break; + + case "progress": + ProcessedCount = evt.ProcessedCount; + TotalCount = evt.TotalCount; + ProcessingMessage = evt.Message; + break; + + case "rule_learned": + AddEvent("rule", evt.Message); + _ = RefreshRulesAsync(); + break; + + case "error": + AddEvent("error", evt.Message); + break; + + case "info": + AddEvent("info", evt.Message); + break; + } + + StateHasChanged(); + _ = ScrollEventLogToBottom(); + }); + } + + private void HandleSessionUpdate(InteractiveLotLinkingSessionDTO session) + { + InvokeAsync(() => + { + ProcessedCount = session.ProcessedCount; + TotalCount = session.TotalCount; + AssignedCount = session.AssignedCount; + CreatedLotsCount = session.CreatedLotsCount; + SkippedCount = session.SkippedCount; + + if (session.CurrentQuestionId != null) + { + CurrentQuestionId = session.CurrentQuestionId; + CurrentQuestion = session.CurrentQuestion; + CurrentAssignment = session.CurrentAssignment; + CurrentOptions = session.CurrentOptions; + CurrentLotOptions = session.CurrentLotOptions; + InitializeLotAllocations(session.CurrentLotOptions); + IsProcessing = false; + } + + StateHasChanged(); + }); + } + + private void HandleError(string error) + { + InvokeAsync(() => + { + ErrorMessage = error; + AddEvent("error", error); + StateHasChanged(); + }); + } + + private void HandleSessionCompleted(LotLinkingStatisticsDTO stats) + { + InvokeAsync(() => + { + IsCompleted = true; + IsProcessing = false; + CurrentQuestion = null; + AddEvent("complete", $"Fertig! {CreatedLotsCount} Lots erstellt, {AssignedCount} zugeordnet, {SkippedCount} übersprungen"); + StateHasChanged(); + }); + } + + 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; + + IsAnswering = true; + StateHasChanged(); + + try + { + var response = new LotLinkingUserResponseDTO + { + QuestionId = CurrentQuestionId, + Response = answer, + ShouldRemember = ShouldRemember, + CustomText = string.IsNullOrWhiteSpace(FreeTextInput) ? null : FreeTextInput + }; + + // Send via SignalR + if (hubConnection?.State == HubConnectionState.Connected) + { + await hubConnection.InvokeAsync("SendLotLinkingResponse", SessionId, response); + } + + // Clear question + 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 RefreshRulesAsync() + { + try + { + var rules = await LotsApi.GetLotLinkingRulesAsync(); + LearnedRules = rules.ToList(); + StateHasChanged(); + } + catch + { + // Ignore + } + } + + private async Task DeleteRule(string ruleId) + { + try + { + var success = await LotsApi.DeleteLotLinkingRuleAsync(ruleId); + if (success) + { + LearnedRules.RemoveAll(r => r.Id == ruleId); + StateHasChanged(); + } + } + catch (Exception ex) + { + ErrorMessage = $"Fehler beim Löschen: {ex.Message}"; + StateHasChanged(); + } + } + + private void ToggleRulesExpanded() + { + RulesExpanded = !RulesExpanded; + } + + private void ShowFreeText() + { + ShowFreeTextInput = true; + FreeTextInput = null; + } + + private void CancelFreeText() + { + ShowFreeTextInput = false; + 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; + + await AnswerQuestion(FreeTextOptionLabel); + } + + 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("airdrop") => "btn-info", + var o when o.Contains("staking") => "btn-info", + var o when o.Contains("mining") => "btn-info", + var o when o.Contains("extern") || o.Contains("eingang") => "btn-success", + var o when o.Contains("skip") || o.Contains("überspringen") => "btn-secondary", + var o when o.Contains("schenkung") || o.Contains("erbe") => "btn-warning", + _ => "btn-primary" + }; + } + + private async Task Complete() + { + await OnComplete.InvokeAsync(); + } + + public async ValueTask DisposeAsync() + { + if (BodyLockApplied) + { + await SetBodyLockAsync(false); + } + + if (hubConnection != null) + { + if (SessionId != null) + { + try + { + await hubConnection.InvokeAsync("LeaveSession", SessionId); + } + catch + { + // Ignore + } + } + await hubConnection.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; } = ""; + public string Message { get; set; } = ""; + public DateTime Timestamp { get; set; } + + public string Icon => Type switch + { + "assigned" => "->", + "lot_created" => "+", + "skipped" => ">>", + "error" => "!", + "rule" => "#", + "info" => "i", + "start" => ">", + "stop" => "[]", + "complete" => "OK", + _ => "*" + }; + + public string CssClass => Type switch + { + "assigned" => "assigned", + "lot_created" => "lot_created", + "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.Client/Shared/LotSelector.razor b/src/CryptoTracker.Client/Shared/LotSelector.razor new file mode 100644 index 0000000..95162a6 --- /dev/null +++ b/src/CryptoTracker.Client/Shared/LotSelector.razor @@ -0,0 +1,353 @@ +@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; + var flowClass = lot.IsFlowComplete ? "" : "lot-incomplete-flow"; +
+
+
Lot #@lot.Id
+
+ + @(lot.IsAltbestand ? "ALTBESTAND" : "Neubestand") + + @if (!lot.IsFlowComplete) + { + + Flow unvollständig + + } +
+
+ @if (!lot.IsFlowComplete && !string.IsNullOrEmpty(lot.FlowIncompleteReason)) + { +
+ + @lot.FlowIncompleteReason +
+ } +
+
+ + @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; } + /// + /// 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; } + + 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: Vollständiger Flow zuerst, dann Altbestand wenn priorisiert, dann nach Datum + if (PrioritizeAltbestand) + { + AvailableLots = lots + .OrderByDescending(l => l.IsFlowComplete) // Vollständiger Flow zuerst + .ThenByDescending(l => l.IsAltbestand) + .ThenBy(l => l.AcquisitionDate) + .ToList(); + } + else + { + AvailableLots = lots + .OrderByDescending(l => l.IsFlowComplete) // Vollständiger Flow zuerst + .ThenBy(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; + + // 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) + { + 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 + // 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)) + { + 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.Client/Shared/TransactionLinkingDTOs.cs b/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs new file mode 100644 index 0000000..2de262d --- /dev/null +++ b/src/CryptoTracker.Client/Shared/TransactionLinkingDTOs.cs @@ -0,0 +1,217 @@ +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; } +} + +/// +/// 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; + public string? Action { get; init; } + public int? VirtualWalletId { get; init; } + public string? VirtualWalletName { get; init; } +} + +/// +/// 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.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.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/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/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/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 new file mode 100644 index 0000000..e1525de --- /dev/null +++ b/src/CryptoTracker/Agent/Definitions/TransactionLinkingAgentDefinition.cs @@ -0,0 +1,78 @@ +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. + + 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, Wallet-Namen, Adressen und Zeitdifferenzen liefern Hinweise + + KONFIDENZ + - >= 0.9: automatisch verknüpfen/markieren + - 0.7-0.9: Benutzer fragen + - < 0.7: Benutzer fragen oder überspringen (`skip_transaction`) + + 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 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) + - `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 + + OUTPUT + Antworte immer auf Deutsch. Halte Antworten kurz. Nutze `ask_user` für Rückfragen. + """; + + public TransactionLinkingAgentDefinition( + GetUnlinkedTransactionsTool getUnlinkedTool, + GetTransactionDetailsTool getDetailsTool, + FindMatchingTransactionsTool findMatchingTool, + LinkTransactionsTool linkTool, + LinkVirtualWalletTool linkVirtualWalletTool, + MarkAsIntentionallyUnlinkedTool markUnlinkedTool, + AskLinkingQuestionTool askUserTool, + SkipTransactionTool skipTool, + LogLinkingEventTool logTool, + 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, + linkVirtualWalletTool, markUnlinkedTool, askUserTool, skipTool, logTool, + saveMemoryTool, getMemoryTool]) + { + } +} diff --git a/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs b/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs new file mode 100644 index 0000000..ca692f9 --- /dev/null +++ b/src/CryptoTracker/Agent/Services/InteractiveLinkingService.cs @@ -0,0 +1,363 @@ +using System.Collections.Concurrent; +using System.Text; +using CryptoTracker.Agent.Common; +using CryptoTracker.Agent.Definitions; +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; + private readonly ILinkingAgentContextAccessor _contextAccessor; + + // Aktive Sessions + private readonly ConcurrentDictionary _sessions = new(); + + public InteractiveLinkingService( + IServiceScopeFactory scopeFactory, + IHubContext hubContext, + ILogger logger, + ILinkingAgentContextAccessor contextAccessor) + { + _scopeFactory = scopeFactory; + _hubContext = hubContext; + _logger = logger; + _contextAccessor = contextAccessor; + } + + /// + /// 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 (agentisch, iterativ) + /// + 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(); + var agentBuilder = scope.ServiceProvider.GetRequiredService(); + + 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); + + var agent = agentBuilder.BuildAgent(TransactionLinkingAgentDefinition.KEY); + + session.History.Add(new ChatMessageDTO + { + 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." + }); + + var lastRemaining = session.TotalCount; + var idleIterations = 0; + + 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))) + { + var result = await agent.RunAsync(prompt, cancellationToken: linkedCt.Token); + if (!string.IsNullOrWhiteSpace(result.Text)) + { + 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; + } + + var remaining = await GetRemainingUnlinkedCountAsync(dbContext, linkedCt.Token); + session.ProcessedCount = Math.Max(0, session.TotalCount - remaining); + await SendSessionUpdateAsync(session); + + if (remaining == 0) + { + break; + } + + if (remaining == lastRemaining) + { + idleIterations++; + if (idleIterations >= 2) + { + 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; + } + } + else + { + idleIterations = 0; + lastRemaining = remaining; + } + + session.History.Add(new ChatMessageDTO + { + Role = "user", + Content = "Bitte fahre mit den verbleibenden Transaktionen fort." + }); + } + + 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 _); + } + } + + private static string BuildAgentInput(InteractiveLinkingSession session, bool allowMemorySave) + { + var sb = new StringBuilder(); + + sb.AppendLine("SESSION-HISTORIE (gekürzt):"); + foreach (var msg in session.History.TakeLast(20)) + { + sb.AppendLine($"{msg.Role.ToUpperInvariant()}: {msg.Content}"); + } + + 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 static string FormatUserResponse(UserResponseDTO response) + { + var sb = new StringBuilder(); + sb.AppendLine("USER_RESPONSE"); + sb.AppendLine($"QuestionId: {response.QuestionId}"); + sb.AppendLine($"Response: {response.Response}"); + if (!string.IsNullOrWhiteSpace(response.Action)) + { + sb.AppendLine($"Action: {response.Action}"); + } + if (response.VirtualWalletId.HasValue) + { + sb.AppendLine($"VirtualWalletId: {response.VirtualWalletId}"); + } + if (!string.IsNullOrWhiteSpace(response.VirtualWalletName)) + { + sb.AppendLine($"VirtualWalletName: {response.VirtualWalletName}"); + } + sb.AppendLine($"ShouldRemember: {response.ShouldRemember}"); + return sb.ToString(); + } + + private static void ResetQuestion(InteractiveLinkingSession session) + { + session.CurrentQuestionId = null; + session.CurrentQuestion = null; + session.CurrentTransaction = null; + session.CurrentOptions = null; + } + + private static Task GetRemainingUnlinkedCountAsync(CryptoTrackerDbContext dbContext, CancellationToken ct) + { + return dbContext.CryptoTransactions + .CountAsync(t => t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked, 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 => + t.OppositeTransactionId == null && !t.IsIntentionallyUnlinked, ct); + + return new LinkingStatisticsDTO + { + TotalTransactions = total, + LinkedTransactions = linked, + IntentionallyUnlinked = external, + UnlinkedTransactions = unlinked + }; + } + + private Task SendEventAsync(InteractiveLinkingSession session, LinkingEventDTO evt) + { + return _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnLinkingEvent", evt); + } + + private Task SendSessionUpdateAsync(InteractiveLinkingSession session) + { + return _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnSessionUpdate", session.ToDTO()); + } +} + +/// +/// Interne Session-Klasse +/// +public sealed 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 bool PendingMemorySave { get; set; } + public CancellationTokenSource CancellationSource { get; } = new(); + internal AsyncQueue UserResponseChannel { get; init; } = default!; + public List History { get; } = new(); + + 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 + }; +} + +/// +/// 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/InteractiveLotLinkingService.cs b/src/CryptoTracker/Agent/Services/InteractiveLotLinkingService.cs new file mode 100644 index 0000000..9a9b033 --- /dev/null +++ b/src/CryptoTracker/Agent/Services/InteractiveLotLinkingService.cs @@ -0,0 +1,412 @@ +using System.Collections.Concurrent; +using System.Text; +using CryptoTracker.Agent.Common; +using CryptoTracker.Agent.Definitions; +using CryptoTracker.Entities; +using CryptoTracker.Hubs; +using CryptoTracker.Services; +using CryptoTracker.Shared; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace CryptoTracker.Agent.Services; + +/// +/// 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(); + + public InteractiveLotLinkingService( + IServiceScopeFactory scopeFactory, + IHubContext hubContext, + ILogger logger, + ILotLinkingAgentContextAccessor contextAccessor) + { + _scopeFactory = scopeFactory; + _hubContext = hubContext; + _logger = logger; + _contextAccessor = contextAccessor; + } + + /// + /// Startet eine neue interaktive Lot-Linking-Session + /// + public async Task StartSessionAsync(CancellationToken ct = default) + { + var sessionId = $"lot-{Guid.NewGuid():N}"; + var session = new InteractiveLotLinkingSession + { + SessionId = sessionId, + StartedAt = DateTimeOffset.UtcNow, + UserResponseChannel = new AsyncQueue() + }; + + _sessions[sessionId] = session; + + // Starte den Linking-Prozess im Hintergrund + _ = Task.Run(() => RunLotLinkingProcessAsync(session, ct), ct); + + return session.ToDTO(); + } + + /// + /// User gibt Antwort auf Agent-Frage + /// + public async Task SubmitUserResponseAsync(string sessionId, LotLinkingUserResponseDTO 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 InteractiveLotLinkingSessionDTO? GetSessionStatus(string sessionId) + { + return _sessions.TryGetValue(sessionId, out var session) ? session.ToDTO() : null; + } + + /// + /// Lädt gelernte Regeln für Lot-Linking + /// + public async Task> GetLearnedRulesAsync(CancellationToken ct = default) + { + using var scope = _scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + return await LoadLearnedRulesAsync(dbContext, ct); + } + + /// + /// Löscht eine gelernte Regel + /// + public async Task DeleteLearnedRuleAsync(string ruleId, CancellationToken ct = default) + { + 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); + await dbContext.SaveChangesAsync(ct); + return true; + } + return false; + } + + /// + /// Hauptprozess für interaktives Lot-Linking (agentisch, iterativ) + /// + private async Task RunLotLinkingProcessAsync(InteractiveLotLinkingSession session, CancellationToken ct) + { + var linkedCt = CancellationTokenSource.CreateLinkedTokenSource(ct, session.CancellationSource.Token); + + try + { + using var scope = _scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var agentBuilder = scope.ServiceProvider.GetRequiredService(); + + 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); + + var agent = agentBuilder.BuildAgent(LotLinkingAgentDefinition.KEY); + + session.History.Add(new ChatMessageDTO + { + 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." + }); + + var lastRemaining = session.TotalCount; + var idleIterations = 0; + + while (!linkedCt.Token.IsCancellationRequested) + { + var allowMemorySave = session.PendingMemorySave; + session.PendingMemorySave = false; + + var prompt = BuildAgentInput(session, allowMemorySave); + + using (_contextAccessor.Use(new LotLinkingAgentContext( + session, + evt => SendEventAsync(session, evt), + allowMemorySave, + allowQuestions: true))) + { + var result = await agent.RunAsync(prompt, cancellationToken: linkedCt.Token); + if (!string.IsNullOrWhiteSpace(result.Text)) + { + 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; + } + + var remaining = await GetPendingAssignmentCountAsync(dbContext, linkedCt.Token); + session.ProcessedCount = Math.Max(0, session.TotalCount - remaining); + await SendSessionUpdateAsync(session); + + if (remaining == 0) + { + 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 + { + idleIterations = 0; + lastRemaining = remaining; + } + + session.History.Add(new ChatMessageDTO + { + Role = "user", + Content = "Bitte fahre mit den verbleibenden Zuordnungen fort." + }); + } + + session.IsActive = false; + var finalStats = await GetStatisticsAsync(dbContext, linkedCt.Token); + + await _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnLotLinkingSessionCompleted", finalStats, linkedCt.Token); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Lot-Linking Session {SessionId} wurde abgebrochen", session.SessionId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler in Lot-Linking Session {SessionId}", session.SessionId); + await _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnLotLinkingError", ex.Message, CancellationToken.None); + } + finally + { + _sessions.TryRemove(session.SessionId, out _); + } + } + + private static string BuildAgentInput(InteractiveLotLinkingSession session, bool allowMemorySave) + { + var sb = new StringBuilder(); + + sb.AppendLine("SESSION-HISTORIE (gekürzt):"); + foreach (var msg in session.History.TakeLast(20)) + { + sb.AppendLine($"{msg.Role.ToUpperInvariant()}: {msg.Content}"); + } + + 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(); + } + + private static string FormatUserResponse(LotLinkingUserResponseDTO response) + { + var sb = new StringBuilder(); + sb.AppendLine("USER_RESPONSE"); + sb.AppendLine($"QuestionId: {response.QuestionId}"); + sb.AppendLine($"Response: {response.Response}"); + if (!string.IsNullOrWhiteSpace(response.CustomText)) + { + sb.AppendLine($"CustomText: {response.CustomText}"); + } + if (response.LotAllocations != null && response.LotAllocations.Count > 0) + { + sb.AppendLine("LotAllocations:"); + foreach (var allocation in response.LotAllocations) + { + sb.AppendLine($"- LotId: {allocation.LotId}, Quantity: {allocation.Quantity}"); + } + } + sb.AppendLine($"ShouldRemember: {response.ShouldRemember}"); + return sb.ToString(); + } + + private static void ResetQuestion(InteractiveLotLinkingSession session) + { + session.CurrentQuestionId = null; + session.CurrentQuestion = null; + session.CurrentAssignment = null; + session.CurrentOptions = null; + session.CurrentLotOptions = null; + } + + private static async Task GetPendingAssignmentCountAsync(CryptoTrackerDbContext dbContext, CancellationToken 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 pendingReceive + pendingSell + pendingBuy; + } + + private static async Task> LoadLearnedRulesAsync( + CryptoTrackerDbContext dbContext, + CancellationToken ct) + { + var memory = await dbContext.AgentMemories + .Where(m => m.AgentKey == "lot-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 static async Task GetStatisticsAsync( + CryptoTrackerDbContext dbContext, + CancellationToken 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); + var completedTx = await dbContext.CryptoTransactions + .CountAsync(t => t.LotAssignmentConfirmed, ct); + var totalLots = await dbContext.AssetLots.CountAsync(ct); + + return new LotLinkingStatisticsDTO + { + TotalPendingAssignments = pendingReceive + pendingSell + pendingBuy, + PendingReceiveTransactions = pendingReceive, + PendingSellTrades = pendingSell, + PendingBuyTrades = pendingBuy, + CompletedAssignments = completedTx, + LotsCreated = totalLots + }; + } + + private Task SendEventAsync(InteractiveLotLinkingSession session, LotLinkingEventDTO evt) + { + return _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnLotLinkingEvent", evt); + } + + private Task SendSessionUpdateAsync(InteractiveLotLinkingSession session) + { + return _hubContext.Clients.Group(session.SessionId) + .SendAsync("OnLotLinkingSessionUpdate", session.ToDTO()); + } +} + +/// +/// Interne Session-Klasse +/// +public sealed class InteractiveLotLinkingSession +{ + 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 AssignedCount { get; set; } + public int CreatedLotsCount { get; set; } + public int SkippedCount { get; set; } + public string? CurrentQuestionId { get; set; } + 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(); + internal AsyncQueue UserResponseChannel { get; init; } = default!; + public List History { get; } = new(); + + public InteractiveLotLinkingSessionDTO ToDTO() => new() + { + SessionId = SessionId, + IsActive = IsActive, + ProcessedCount = ProcessedCount, + TotalCount = TotalCount, + AssignedCount = AssignedCount, + CreatedLotsCount = CreatedLotsCount, + SkippedCount = SkippedCount, + CurrentQuestionId = CurrentQuestionId, + CurrentQuestion = CurrentQuestion, + CurrentAssignment = CurrentAssignment, + CurrentOptions = CurrentOptions, + CurrentLotOptions = CurrentLotOptions + }; +} diff --git a/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs b/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs new file mode 100644 index 0000000..18ffca3 --- /dev/null +++ b/src/CryptoTracker/Agent/Services/TransactionLinkingService.cs @@ -0,0 +1,315 @@ +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 (LLM-basiert, ohne Rückfragen) + /// + public async Task RunAutomaticLinkingAsync(CancellationToken ct = default) + { + if (!IsConfigured) + { + return new AutoLinkResult + { + Summary = "AI-Service nicht konfiguriert.", + Success = false + }; + } + + try + { + var before = await GetStatisticsAsync(ct); + + 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); + + var after = await GetStatisticsAsync(ct); + + 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 = after.UnlinkedTransactions, + Summary = string.IsNullOrWhiteSpace(result.Text) ? "Auto-Link abgeschlossen." : 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/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/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/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/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..071fde6 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/LinkTransactionsTool.cs @@ -0,0 +1,139 @@ +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 Verknüpfen von zwei Transaktionen +/// +public sealed class LinkTransactionsTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILinkingAgentContextAccessor _contextAccessor; + + public LinkTransactionsTool( + CryptoTrackerDbContext dbContext, + ILinkingAgentContextAccessor contextAccessor) + { + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + 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(); + + 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, + 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/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 new file mode 100644 index 0000000..cf75084 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/MarkAsIntentionallyUnlinkedTool.cs @@ -0,0 +1,127 @@ +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 Markieren einer Transaktion als bewusst unverknüpft +/// +public sealed class MarkAsIntentionallyUnlinkedTool : IAgentTool +{ + private readonly CryptoTrackerDbContext _dbContext; + private readonly ILinkingAgentContextAccessor _contextAccessor; + + public MarkAsIntentionallyUnlinkedTool( + CryptoTrackerDbContext dbContext, + ILinkingAgentContextAccessor contextAccessor) + { + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + 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 + .Include(t => t.Wallet) + .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(); + + 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, + 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..ba4bff1 --- /dev/null +++ b/src/CryptoTracker/Agent/Tools/SaveAgentMemoryTool.cs @@ -0,0 +1,110 @@ +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; + private readonly ILinkingAgentContextAccessor _contextAccessor; + + public SaveAgentMemoryTool( + CryptoTrackerDbContext dbContext, + ILinkingAgentContextAccessor contextAccessor) + { + _dbContext = dbContext; + _contextAccessor = contextAccessor; + } + + 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) + { + 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 = "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/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/Components/Layout/NavMenu.razor b/src/CryptoTracker/Components/Layout/NavMenu.razor index 5db0791..d6919a3 100644 --- a/src/CryptoTracker/Components/Layout/NavMenu.razor +++ b/src/CryptoTracker/Components/Layout/NavMenu.razor @@ -4,6 +4,9 @@ + + + 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/LotsController.cs b/src/CryptoTracker/Controllers/LotsController.cs new file mode 100644 index 0000000..0e2ab86 --- /dev/null +++ b/src/CryptoTracker/Controllers/LotsController.cs @@ -0,0 +1,468 @@ +using CryptoTracker.Agent.Services; +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 LotFlowValidator _flowValidator; + private readonly CryptoTrackerDbContext _dbContext; + private readonly InteractiveLotLinkingService _lotLinkingService; + + public LotsController( + LotService lotService, + LotFlowValidator flowValidator, + CryptoTrackerDbContext dbContext, + InteractiveLotLinkingService lotLinkingService) + { + _lotService = lotService; + _flowValidator = flowValidator; + _dbContext = dbContext; + _lotLinkingService = lotLinkingService; + } + + #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); + } + + [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 + + [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.WalletId, + t.TransactionType.ToString(), + t.OppositeWallet?.Name, + t.Comment) + { + OppositeTransactionId = t.OppositeTransactionId + })); + + result.AddRange(trades.Select(t => new PendingLotAssignmentDTO( + "Trade", + t.Id, + t.DateTime, + t.Symbol, + t.Quantity, + t.Wallet.Name, + t.WalletId, + t.TradeType.ToString(), + null, + t.Comment) + { + OppositeTradeId = t.OppositeTradeId, + OppositeSymbol = t.OppositeSymbol + })); + + 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 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) + => 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); + + Task ILotsApi.StartInteractiveLotLinkingSessionAsync() + => StartInteractiveLotLinkingSession(); + + Task ILotsApi.StopInteractiveLotLinkingSessionAsync(string sessionId) + => StopInteractiveLotLinkingSession(sessionId); + + Task> ILotsApi.GetLotLinkingRulesAsync() + => GetLotLinkingRules(); + + Task ILotsApi.DeleteLotLinkingRuleAsync(string ruleId) + => DeleteLotLinkingRule(ruleId); + + Task ILotsApi.GetLotLinkingStatisticsAsync() + => GetLotLinkingStatistics(); + + #endregion + + #region Interactive Lot-Linking + + [HttpPost("StartInteractiveLotLinkingSession")] + public async Task StartInteractiveLotLinkingSession() + { + return await _lotLinkingService.StartSessionAsync(); + } + + [HttpPost("StopInteractiveLotLinkingSession")] + public async Task StopInteractiveLotLinkingSession([FromQuery] string sessionId) + { + _lotLinkingService.StopSession(sessionId); + await Task.CompletedTask; + } + + [HttpGet("GetLotLinkingRules")] + public async Task> GetLotLinkingRules() + { + return await _lotLinkingService.GetLearnedRulesAsync(); + } + + [HttpDelete("DeleteLotLinkingRule")] + public async Task DeleteLotLinkingRule([FromQuery] string ruleId) + { + return await _lotLinkingService.DeleteLearnedRuleAsync(ruleId); + } + + [HttpGet("GetLotLinkingStatistics")] + public async Task GetLotLinkingStatistics() + { + var pendingReceive = await _dbContext.CryptoTransactions + .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 + pendingBuy, + PendingReceiveTransactions = pendingReceive, + PendingSellTrades = pendingSell, + PendingBuyTrades = pendingBuy, + CompletedAssignments = completedTx, + LotsCreated = totalLots + }; + } + + #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, + lot.IsFlowComplete, + lot.FlowIncompleteReason); + + #endregion +} diff --git a/src/CryptoTracker/Controllers/TransactionLinkingController.cs b/src/CryptoTracker/Controllers/TransactionLinkingController.cs new file mode 100644 index 0000000..be03d80 --- /dev/null +++ b/src/CryptoTracker/Controllers/TransactionLinkingController.cs @@ -0,0 +1,496 @@ +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 InteractiveLinkingService _interactiveService; + private readonly CryptoTrackerDbContext _dbContext; + + public TransactionLinkingController( + TransactionLinkingService linkingService, + InteractiveLinkingService interactiveService, + CryptoTrackerDbContext dbContext) + { + _linkingService = linkingService; + _interactiveService = interactiveService; + _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" }; + } + + // === 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/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/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 3fb2448..8c3e9f9 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,10 @@ 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 DbSet TransactionLinkMetadata { get; set; } + public DbSet AgentMemories { get; set; } public CryptoTrackerDbContext(DbContextOptions options) : base(options) { @@ -108,6 +112,126 @@ 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() + .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() + .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); + modelBuilder.Entity() + .HasOne(t => t.SourceLot) + .WithMany() + .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/AssetLot.cs b/src/CryptoTracker/Entities/AssetLot.cs new file mode 100644 index 0000000..5f7c281 --- /dev/null +++ b/src/CryptoTracker/Entities/AssetLot.cs @@ -0,0 +1,180 @@ +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(); + + // === 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 + /// + 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 bb3ec84..166a681 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; @@ -59,6 +59,33 @@ 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; } + + /// + /// 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. + /// + 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 980c8df..9f55fd1 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; @@ -54,6 +54,47 @@ 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 === + + /// + /// 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/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/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/Hubs/LinkingHub.cs b/src/CryptoTracker/Hubs/LinkingHub.cs new file mode 100644 index 0000000..359b5ac --- /dev/null +++ b/src/CryptoTracker/Hubs/LinkingHub.cs @@ -0,0 +1,83 @@ +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 +/// (Transaktions-Verknüpfung und Lot-Verknüpfung) +/// +public class LinkingHub : Hub +{ + private readonly InteractiveLinkingService _linkingService; + private readonly InteractiveLotLinkingService _lotLinkingService; + + public LinkingHub( + InteractiveLinkingService linkingService, + InteractiveLotLinkingService lotLinkingService) + { + _linkingService = linkingService; + _lotLinkingService = lotLinkingService; + } + + /// + /// 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 (Transaktions-Verknüpfung) + /// + public async Task SendUserResponse(string sessionId, UserResponseDTO response) + { + // Weiterleiten an den InteractiveLinkingService + await _linkingService.SubmitUserResponseAsync(sessionId, response); + } + + /// + /// User sendet Antwort auf Agent-Frage (Lot-Verknüpfung) + /// + public async Task SendLotLinkingResponse(string sessionId, LotLinkingUserResponseDTO response) + { + // Weiterleiten an den InteractiveLotLinkingService + await _lotLinkingService.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/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("20260201160654_TransactionLinkingAndLots")] + partial class TransactionLinkingAndLots + { + /// + 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("IsHidden") + .HasColumnType("bit"); + + 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("IsHidden") + .HasColumnType("bit"); + + 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("IsVirtual") + .HasColumnType("bit"); + + 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/20260201160654_TransactionLinkingAndLots.cs b/src/CryptoTracker/Migrations/20260201160654_TransactionLinkingAndLots.cs new file mode 100644 index 0000000..0669d82 --- /dev/null +++ b/src/CryptoTracker/Migrations/20260201160654_TransactionLinkingAndLots.cs @@ -0,0 +1,401 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CryptoTracker.Migrations +{ + /// + 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", + 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.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 + { + 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), + 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) + }, + 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_AssetLots_TransformedToLotId", + column: x => x.TransformedToLotId, + 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: "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 + { + 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_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", + 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_AssetLots_TransformedToLotId", + table: "AssetLots", + column: "TransformedToLotId"); + + 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.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", + column: "ResultingLotId", + 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); + + 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_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"); + + migrationBuilder.DropIndex( + name: "IX_CryptoTransactions_ResultingLotId", + table: "CryptoTransactions"); + + migrationBuilder.DropIndex( + 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"); + + migrationBuilder.DropColumn( + name: "ResultingLotId", + table: "CryptoTransactions"); + + migrationBuilder.DropColumn( + name: "LotAssignmentConfirmed", + table: "CryptoTrades"); + + 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 7b9173a..d156349 100644 --- a/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs +++ b/src/CryptoTracker/Migrations/CryptoTrackerDbContextModelSnapshot.cs @@ -22,6 +22,122 @@ 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") + .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") @@ -49,6 +165,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsHidden") .HasColumnType("bit"); + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + b.Property("OppositeSymbol") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -65,6 +184,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Referenz") .HasColumnType("nvarchar(max)"); + b.Property("ResultingLotId") + .HasColumnType("int"); + + b.Property("SourceLotId") + .HasColumnType("int"); + b.Property("Symbol") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -81,6 +206,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasFilter("[OppositeTradeId] IS NOT NULL"); + b.HasIndex("ResultingLotId"); + + b.HasIndex("SourceLotId"); + b.HasIndex("WalletId"); b.ToTable("CryptoTrades"); @@ -109,6 +238,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsHidden") .HasColumnType("bit"); + b.Property("IsIntentionallyUnlinked") + .HasColumnType("bit"); + + b.Property("LotAssignmentConfirmed") + .HasColumnType("bit"); + b.Property("Network") .HasColumnType("nvarchar(max)"); @@ -121,6 +256,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)"); @@ -142,6 +280,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OppositeWalletId"); + b.HasIndex("ResultingLotId"); + b.HasIndex("WalletId"); b.ToTable("CryptoTransactions"); @@ -667,6 +807,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") @@ -696,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") @@ -704,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)"); @@ -716,6 +962,45 @@ 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.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") @@ -723,6 +1008,16 @@ 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.AssetLot", "SourceLot") + .WithMany() + .HasForeignKey("SourceLotId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("CryptoTracker.Entities.Wallet", "Wallet") .WithMany() .HasForeignKey("WalletId") @@ -731,6 +1026,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("OppositeTrade"); + b.Navigation("ResultingLot"); + + b.Navigation("SourceLot"); + b.Navigation("Wallet"); }); @@ -746,6 +1045,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") @@ -756,6 +1060,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("OppositeWallet"); + b.Navigation("ResultingLot"); + b.Navigation("Wallet"); }); @@ -868,6 +1174,70 @@ 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.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/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/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/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/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 new file mode 100644 index 0000000..fddb819 --- /dev/null +++ b/src/CryptoTracker/Services/LotService.cs @@ -0,0 +1,770 @@ +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). + /// + /// 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() + .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, bool onlyCompleteFlow = false) + { + 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); + + 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, bool onlyCompleteFlow = false) + { + 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); + + if (onlyCompleteFlow) + { + query = query.Where(l => l.IsFlowComplete); + } + + return await query.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 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 + + /// + /// 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 + + /// + /// Generiert Lots aus bestehenden Buy-Trades die noch kein Lot haben. + /// + public async Task GenerateLotsFromExistingTradesAsync() + { + // 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 + && FiatSymbols.ForQuery.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; + } + + #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/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/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) diff --git a/src/CryptoTracker/Startup.cs b/src/CryptoTracker/Startup.cs index 6e13b58..c77f048 100644 --- a/src/CryptoTracker/Startup.cs +++ b/src/CryptoTracker/Startup.cs @@ -7,6 +7,14 @@ 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 CryptoTracker.Hubs; +using Azure.AI.OpenAI; +using Azure; +using Azure.Identity; namespace CryptoTracker { @@ -66,16 +74,101 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + 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(); + 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(); + services.AddSingleton(); + services.AddSingleton(); + + // SignalR + services.AddSignalR(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -109,6 +202,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"); }); diff --git a/src/CryptoTracker/wwwroot/app.css b/src/CryptoTracker/wwwroot/app.css index cd3071d..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; @@ -831,3 +837,1952 @@ 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-card.details-modal { + width: min(860px, 92vw); + max-width: 860px; + max-height: 85vh; + overflow: 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); + } +} + +/* ============================================ + 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; +} + +/* ============================================ + 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; + 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 { + 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; +} + 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(); + } +})();