using System; using System.Collections.Generic; using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; using FinlyticBackend.Util; using FinlyticCore.Dtos; using FinlyticCore.Dtos.Trading; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace FinlyticBackend.Controllers; [ApiController] [Authorize] [Route("api/v1/user/trades")] public class UserTradesController : ControllerBase { private readonly BackendMqttBridge _mqttClient; private readonly ILogger _logger; public UserTradesController(BackendMqttBridge mqttClient, ILogger logger) { _mqttClient = mqttClient; _logger = logger; } /// /// Resolves the authenticated caller's identity from the JWT NameIdentifier claim, parsed as a /// exactly like the global user-validation middleware in /// FinlyticBackend/Program.cs (see line ~146). There is deliberately no pseudo-identity fallback /// (e.g. a shared "default_user" string): every trade in FinlyticEngine is tenant-scoped by /// EngineTradeEntity.UserId, so a request whose identity cannot be established must be rejected /// with 401 rather than silently attributed to a placeholder user that could leak or merge data across /// tenants. /// /// /// The NameIdentifier claim (or its sub/nameid fallbacks) is missing or is not a /// parseable . /// private Guid GetUserIdFromClaims() { var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? User.FindFirstValue("nameid"); if (Guid.TryParse(claimUserId, out var userId)) { return userId; } _logger.LogWarning("[UserTradesController] Claim NameIdentifier missing or not a valid GUID for an authenticated request."); throw new UnauthorizedAccessException("The request does not carry a valid user identity claim."); } /// /// Retrieves a list of active trades or proposals from FinlyticEngine. /// [HttpGet] public async Task GetUserTrades([FromQuery] string? isin = null, [FromQuery] string? status = null) { try { if (!_mqttClient.IsConnected) { return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." }); } if (string.Equals(status, "Proposed", StringComparison.OrdinalIgnoreCase)) { // Proposals are system-wide opportunities, not owned by any user, so no UserId is attached here. var req = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50); var proposals = await _mqttClient.SendRpcRequestAsync, GetTradeProposalsRequest>( "engine_GetProposals", req, TimeSpan.FromSeconds(5)); return Ok(proposals ?? new List()); } else { var userId = GetUserIdFromClaims(); var req = new GetActiveTradesRequest(UserId: userId, Mode: null); var trades = await _mqttClient.SendRpcRequestAsync, GetActiveTradesRequest>( "engine_GetTrades", req, TimeSpan.FromSeconds(5)); return Ok(trades ?? new List()); } } catch (UnauthorizedAccessException) { return Unauthorized(new { error = "A valid user identity is required." }); } catch (Exception ex) { _logger.LogError(ex, "Failed to retrieve trades from FinlyticEngine."); return StatusCode(500, new { error = "Internal server error while fetching trades" }); } } /// /// Accepts a trade proposal and converts it into an actively tracked trade in FinlyticEngine, owned by the /// authenticated caller. /// /// /// The acceptance payload sent by the client (see TradeRepository.acceptTrade in FinlyticApp), /// carrying the proposal identifier () /// plus any user-adjusted position sizing, leverage and fee details. Any userId supplied by the /// client is discarded; the acceptance is always attributed to the identity from the JWT. /// /// /// The resulting on success, or a diagnostic error response if /// FinlyticEngine is unreachable or rejects the request (e.g. proposal expired, not found, or already /// accepted by this same user). /// [HttpPost("accept")] public async Task AcceptTrade([FromBody] FinlyticCore.Models.Trades.TradeAcceptanceDto dto) { if (dto == null || string.IsNullOrWhiteSpace(dto.TradeId) || !Guid.TryParse(dto.TradeId, out var proposalId)) { return BadRequest(new { error = "A valid proposal id ('tradeId') is required." }); } if (!_mqttClient.IsConnected) { return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." }); } try { // The acceptance is always attributed to the authenticated caller, never to a client-supplied value. var userId = GetUserIdFromClaims(); var acceptRequest = new AcceptTradeProposalRequest( UserId: userId, ProposalId: proposalId, ExecutedPrice: dto.ActualEntryPrice ?? dto.EntryPrice, Quantity: dto.Quantity ?? dto.PositionSize); var acceptedTrade = await _mqttClient.SendRpcRequestAsync( "engine_AcceptProposal", acceptRequest, TimeSpan.FromSeconds(10)); if (acceptedTrade != null) { return Ok(acceptedTrade); } return StatusCode(504, new { error = "FinlyticEngine did not confirm the trade acceptance in time." }); } catch (UnauthorizedAccessException) { return Unauthorized(new { error = "A valid user identity is required." }); } catch (InvalidOperationException ex) { _logger.LogWarning(ex, "Cannot accept proposal {ProposalId}: rejected by FinlyticEngine (expired, not found, or already accepted by this user).", proposalId); return Conflict(new { error = "The proposal is no longer available or has already been accepted by this user." }); } catch (JsonException ex) { _logger.LogError(ex, "Malformed RPC response while accepting proposal {ProposalId}.", proposalId); return StatusCode(502, new { error = "FinlyticEngine returned an unexpected response while accepting the trade." }); } } /// /// Manually opens a trade in FinlyticEngine with no backing proposal (e.g. the user enters a position in /// the Web UI that was never evaluated/scored by FinlyticEngine). This is the alternative to /// : a user either accepts an existing proposal or creates a trade from scratch, /// and both paths end up with one user-owned . /// /// /// The manual trade payload from the client. Any userId it carries is discarded; the trade is /// always attributed to the identity from the JWT. /// [HttpPost("manual")] public async Task CreateManualTrade([FromBody] CreateManualTradeRequest request) { if (request == null) { return Problem(title: "A request body is required.", statusCode: StatusCodes.Status400BadRequest); } if (!_mqttClient.IsConnected) { return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable); } try { // The trade is always attributed to the authenticated caller, never to a client-supplied value. var userId = GetUserIdFromClaims(); var manualTradeRequest = request with { UserId = userId }; var trade = await _mqttClient.SendRpcRequestAsync( "engine_CreateManualTrade", manualTradeRequest, TimeSpan.FromSeconds(10)); if (trade != null) { return Ok(trade); } return Problem(title: "FinlyticEngine did not confirm the manual trade in time.", statusCode: StatusCodes.Status504GatewayTimeout); } catch (UnauthorizedAccessException) { return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized); } catch (ArgumentException ex) { _logger.LogWarning(ex, "Rejected manual trade creation: invalid request payload."); return Problem(title: "The manual trade payload is invalid.", detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); } catch (JsonException ex) { _logger.LogError(ex, "Malformed RPC response while creating a manual trade."); return Problem(title: "FinlyticEngine returned an unexpected response while creating the manual trade.", statusCode: StatusCodes.Status502BadGateway); } catch (Exception ex) { _logger.LogError(ex, "Failed to create manual trade via FinlyticEngine."); return Problem(title: "Internal server error while creating the manual trade.", statusCode: StatusCodes.Status500InternalServerError); } } /// /// Closes an active trade via FinlyticEngine. The trade must be owned by the authenticated caller; /// FinlyticEngine enforces this server-side and reports an unknown/foreign trade identically (Rules.md /// multi-tenancy requirement), so this action never leaks whether a trade ID belongs to another user. /// [HttpPost("{id:guid}/close")] public async Task CloseTrade(Guid id, [FromBody] CloseEngineTradeRequest? request) { try { if (!_mqttClient.IsConnected) { return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." }); } var userId = GetUserIdFromClaims(); // Any UserId/TradeId supplied by the client in the body is discarded and replaced with the // authenticated identity and the route value, so a caller cannot target another user's trade. var closeReq = (request ?? new CloseEngineTradeRequest(UserId: userId, TradeId: id, ClosePrice: 0m, Reason: "UserManualClose")) with { UserId = userId, TradeId = id }; var result = await _mqttClient.SendRpcRequestAsync( "engine_CloseTrade", closeReq, TimeSpan.FromSeconds(5)); if (result != null) { return Ok(result); } return NotFound(new { error = $"Trade {id} not found." }); } catch (UnauthorizedAccessException) { return Unauthorized(new { error = "A valid user identity is required." }); } catch (Exception ex) { _logger.LogError(ex, "Failed to close trade {Id} via FinlyticEngine.", id); return StatusCode(500, new { error = "Internal server error while closing trade" }); } } }