From e12c2e5db5a36b13b2d4f8af2b375ddf34473f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 18 Apr 2026 22:38:02 +0200 Subject: [PATCH] Fix TradeRepublic rebase CI failures --- Gemfile.lock | 7 + .../traderepublic_items_controller.rb | 98 +- app/models/holding/forward_calculator.rb | 14 +- app/models/provider/traderepublic.rb | 438 ++++---- app/models/provider/traderepublic_adapter.rb | 6 +- app/models/traderepublic_account/processor.rb | 958 +++++++++--------- app/models/traderepublic_item.rb | 20 +- app/models/traderepublic_item/importer.rb | 316 +++--- app/models/traderepublic_item/syncer.rb | 4 +- db/schema.rb | 4 - 10 files changed, 927 insertions(+), 938 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b962e3d28..00e4a6f52 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -199,6 +199,7 @@ GEM erubi (1.13.1) et-orbi (1.2.11) tzinfo + event_emitter (0.2.6) event_stream_parser (1.0.0) faker (3.5.2) i18n (>= 1.8.11, < 2) @@ -760,6 +761,11 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) + websocket-client-simple (0.9.0) + base64 + event_emitter + mutex_m + websocket websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -875,6 +881,7 @@ DEPENDENCIES view_component web-console webmock + websocket-client-simple RUBY VERSION ruby 3.4.7p58 diff --git a/app/controllers/traderepublic_items_controller.rb b/app/controllers/traderepublic_items_controller.rb index 5e4072eb3..a46f23b82 100644 --- a/app/controllers/traderepublic_items_controller.rb +++ b/app/controllers/traderepublic_items_controller.rb @@ -89,14 +89,14 @@ class TraderepublicItemsController < ApplicationController end def complete_login - @traderepublic_item = Current.family.traderepublic_items.find(params[:id]) - device_pin = params[:device_pin] - manual_sync = params[:manual_sync].to_s == 'true' || params[:manual_sync] == '1' + @traderepublic_item = Current.family.traderepublic_items.find(params[:id]) + device_pin = params[:device_pin] + manual_sync = params[:manual_sync].to_s == "true" || params[:manual_sync] == "1" - if device_pin.blank? - render json: { success: false, error: t(".pin_required", default: "PIN is required") }, status: :unprocessable_entity - return - end + if device_pin.blank? + render json: { success: false, error: t(".pin_required", default: "PIN is required") }, status: :unprocessable_entity + return + end begin success = @traderepublic_item.complete_login!(device_pin) @@ -143,9 +143,9 @@ class TraderepublicItemsController < ApplicationController } end else - render json: { - success: false, - error: t(".sync_failed", default: "Connection successful but failed to fetch accounts. Please try syncing manually.") + render json: { + success: false, + error: t(".sync_failed", default: "Connection successful but failed to fetch accounts. Please try syncing manually.") }, status: :unprocessable_entity end end @@ -252,12 +252,12 @@ class TraderepublicItemsController < ApplicationController accountable_type: accountable_type, accountable_attributes: {} ) - + Account.transaction do account.save! # Skip opening balance creation entirely for TradeRepublic accounts end - + account.sync_later # Link account via account_providers @@ -272,21 +272,21 @@ class TraderepublicItemsController < ApplicationController if created_accounts.any? # Reload to pick up the newly created account_provider associations traderepublic_item.reload - + # Process transactions immediately for the newly linked accounts # This creates Entry records from the raw transaction data traderepublic_item.process_accounts - + # Trigger full sync in background to update balances and get latest data traderepublic_item.sync_later # Redirect to the newly created account if single account, or accounts list if multiple # Avoid redirecting back to /accounts/new redirect_path = if return_to == new_account_path || return_to.blank? - created_accounts.size == 1 ? account_path(created_accounts.first) : accounts_path - else - return_to - end + created_accounts.size == 1 ? account_path(created_accounts.first) : accounts_path + else + return_to + end redirect_to redirect_path, notice: t(".accounts_linked", count: created_accounts.count, @@ -313,7 +313,7 @@ class TraderepublicItemsController < ApplicationController def destroy @traderepublic_item.destroy_later - + respond_to do |format| format.turbo_stream do flash.now[:notice] = t(".scheduled_for_deletion", default: "Trade Republic connection scheduled for deletion") @@ -330,7 +330,7 @@ class TraderepublicItemsController < ApplicationController def sync @traderepublic_item.sync_later - + respond_to do |format| format.turbo_stream do flash.now[:notice] = t(".sync_started", default: "Sync started") @@ -349,7 +349,7 @@ class TraderepublicItemsController < ApplicationController Rails.logger.info "TradeRepublic reauthenticate action called" Rails.logger.info "Request format: #{request.format}" Rails.logger.info "Turbo frame: #{request.headers['Turbo-Frame']}" - + begin result = @traderepublic_item.initiate_login! Rails.logger.info "Login initiated successfully" @@ -370,7 +370,7 @@ class TraderepublicItemsController < ApplicationController end rescue TraderepublicError => e Rails.logger.error "TradeRepublic re-authentication initiation failed: #{e.message}" - + respond_to do |format| format.turbo_stream do flash.now[:alert] = t(".login_failed", default: "Re-authentication failed: #{e.message}") @@ -457,35 +457,35 @@ class TraderepublicItemsController < ApplicationController private - def set_traderepublic_item - @traderepublic_item = Current.family.traderepublic_items.find(params[:id]) - end - - def traderepublic_item_params - params.fetch(:traderepublic_item, {}).permit(:name, :phone_number, :pin) - end - - def safe_return_to_path - return_to_raw = params[:return_to].to_s - return new_account_path if return_to_raw.blank? - - decoded = CGI.unescape(return_to_raw) - begin - uri = URI.parse(decoded) - rescue URI::InvalidURIError - return new_account_path + def set_traderepublic_item + @traderepublic_item = Current.family.traderepublic_items.find(params[:id]) end - # Only allow local paths: no scheme, no host, starts with a single leading slash (not protocol-relative //) - path = uri.path || decoded - if uri.scheme.nil? && uri.host.nil? && path.start_with?("/") && !path.start_with?("//") - # Rebuild path with query and fragment if present - built = path - built += "?#{uri.query}" if uri.query.present? - built += "##{uri.fragment}" if uri.fragment.present? - return built + def traderepublic_item_params + params.fetch(:traderepublic_item, {}).permit(:name, :phone_number, :pin) end - new_account_path - end + def safe_return_to_path + return_to_raw = params[:return_to].to_s + return new_account_path if return_to_raw.blank? + + decoded = CGI.unescape(return_to_raw) + begin + uri = URI.parse(decoded) + rescue URI::InvalidURIError + return new_account_path + end + + # Only allow local paths: no scheme, no host, starts with a single leading slash (not protocol-relative //) + path = uri.path || decoded + if uri.scheme.nil? && uri.host.nil? && path.start_with?("/") && !path.start_with?("//") + # Rebuild path with query and fragment if present + built = path + built += "?#{uri.query}" if uri.query.present? + built += "##{uri.fragment}" if uri.fragment.present? + return built + end + + new_account_path + end end diff --git a/app/models/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb index 03f9fcb8e..26542c031 100644 --- a/app/models/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -29,12 +29,12 @@ class Holding::ForwardCalculator prev_qty = nil sorted.each do |h| # Note: this condition (h.qty.to_f > 0 && h.amount.to_f > 0) - # intentionally filters out holdings where quantity > 0 but amount == 0 - # (for example when price is missing or zero). If zero-amount records - # should be treated as valid, consider falling back to a price lookup - # or include qty>0 entries and compute amount from a known price. - if h.qty.to_f > 0 && h.amount.to_f > 0 - valid_holdings << h + # intentionally filters out holdings where quantity > 0 but amount == 0 + # (for example when price is missing or zero). If zero-amount records + # should be treated as valid, consider falling back to a price lookup + # or include qty>0 entries and compute amount from a known price. + if h.qty.to_f > 0 && h.amount.to_f > 0 + valid_holdings << h elsif h.qty.to_f == 0 if prev_qty.nil? # Allow initial zero holding (initial portfolio state) @@ -47,7 +47,7 @@ class Holding::ForwardCalculator prev_qty = h.qty.to_f end end - Holding.gapfill(valid_holdings) + Holding.gapfill(valid_holdings) end end diff --git a/app/models/provider/traderepublic.rb b/app/models/provider/traderepublic.rb index 8f1ef1eb6..ce3d07aad 100644 --- a/app/models/provider/traderepublic.rb +++ b/app/models/provider/traderepublic.rb @@ -2,67 +2,67 @@ require "websocket-client-simple" require "json" class Provider::Traderepublic - # Batch fetch instrument details for a list of ISINs - # Returns a hash { isin => instrument_details } - def batch_fetch_instrument_details(isins) - results = {} - batch_websocket_calls do |batch| - isins.uniq.each do |isin| - results[isin] = batch.get_instrument_details(isin) - end - end - results - end - # Helper: Get portfolio, cash et available_cash en un seul batch WebSocket - def get_portfolio_and_cash_batch - results = {} - batch_websocket_calls do |batch| - results[:portfolio] = batch.get_portfolio - results[:cash] = batch.get_cash - results[:available_cash] = batch.get_available_cash - end - results + # Batch fetch instrument details for a list of ISINs + # Returns a hash { isin => instrument_details } + def batch_fetch_instrument_details(isins) + results = {} + batch_websocket_calls do |batch| + isins.uniq.each do |isin| + results[isin] = batch.get_instrument_details(isin) end - # Execute several subscribe_once calls in a single WebSocket session - # Usage: batch_websocket_calls { |batch| batch.get_portfolio; batch.get_cash } - def batch_websocket_calls - connect_websocket - batch_proxy = BatchWebSocketProxy.new(self) - yield batch_proxy - # Optionally, small sleep to allow last messages to arrive - sleep 0.5 - ensure - disconnect_websocket + end + results + end + # Helper: Get portfolio, cash et available_cash en un seul batch WebSocket + def get_portfolio_and_cash_batch + results = {} + batch_websocket_calls do |batch| + results[:portfolio] = batch.get_portfolio + results[:cash] = batch.get_cash + results[:available_cash] = batch.get_available_cash + end + results + end + # Execute several subscribe_once calls in a single WebSocket session + # Usage: batch_websocket_calls { |batch| batch.get_portfolio; batch.get_cash } + def batch_websocket_calls + connect_websocket + batch_proxy = BatchWebSocketProxy.new(self) + yield batch_proxy + # Optionally, small sleep to allow last messages to arrive + sleep 0.5 + ensure + disconnect_websocket + end + + # Proxy to expose only subscribe_once helpers on an open connection + class BatchWebSocketProxy + def initialize(provider) + @provider = provider end - # Proxy to expose only subscribe_once helpers on an open connection - class BatchWebSocketProxy - def initialize(provider) - @provider = provider - end - - def get_portfolio - @provider.subscribe_once("compactPortfolioByType") - end - - def get_cash - @provider.subscribe_once("cash") - end - - def get_available_cash - @provider.subscribe_once("availableCash") - end - - def get_timeline_detail(id) - @provider.subscribe_once("timelineDetailV2", { id: id }) - end - - def get_instrument_details(isin) - @provider.subscribe_once("instrument", { id: isin }) - end - - # Ajoutez ici d'autres helpers si besoin + def get_portfolio + @provider.subscribe_once("compactPortfolioByType") end + + def get_cash + @provider.subscribe_once("cash") + end + + def get_available_cash + @provider.subscribe_once("availableCash") + end + + def get_timeline_detail(id) + @provider.subscribe_once("timelineDetailV2", { id: id }) + end + + def get_instrument_details(isin) + @provider.subscribe_once("instrument", { id: isin }) + end + + # Ajoutez ici d'autres helpers si besoin + end include HTTParty headers "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" @@ -102,15 +102,15 @@ class Provider::Traderepublic phoneNumber: @phone_number, pin: @pin } - + Rails.logger.info "TradeRepublic: Initiating login for phone: #{@phone_number.to_s.gsub(/\d(?=\d{4})/, '*')}" sanitized_payload = payload.dup if sanitized_payload[:phoneNumber] - sanitized_payload[:phoneNumber] = sanitized_payload[:phoneNumber].to_s.gsub(/\d(?=\d{4})/, '*') + sanitized_payload[:phoneNumber] = sanitized_payload[:phoneNumber].to_s.gsub(/\d(?=\d{4})/, "*") end - sanitized_payload[:pin] = '[FILTERED]' if sanitized_payload.key?(:pin) + sanitized_payload[:pin] = "[FILTERED]" if sanitized_payload.key?(:pin) Rails.logger.debug "TradeRepublic: Request payload: #{sanitized_payload.to_json}" - + response = self.class.post( "#{HOST}/api/v1/auth/web/login", headers: default_headers, @@ -124,7 +124,7 @@ class Provider::Traderepublic # Extract and store JSESSIONID cookie for subsequent requests if response.headers["set-cookie"] set_cookies = response.headers["set-cookie"] - set_cookies = [set_cookies] unless set_cookies.is_a?(Array) + set_cookies = [ set_cookies ] unless set_cookies.is_a?(Array) set_cookies.each do |cookie| if cookie.start_with?("JSESSIONID=") @jsessionid = cookie.split(";").first @@ -147,17 +147,17 @@ class Provider::Traderepublic url = "#{HOST}/api/v1/auth/web/login/#{@process_id}/#{device_pin}" headers = default_headers - + # Include JSESSIONID cookie if available if @jsessionid headers["Cookie"] = @jsessionid Rails.logger.info "TradeRepublic: Including JSESSIONID in verification request" end - + Rails.logger.info "TradeRepublic: Verifying device PIN for processId: #{@process_id}" Rails.logger.debug "TradeRepublic: Verification URL: #{url}" Rails.logger.debug "TradeRepublic: Verification headers: #{headers.inspect}" - + # IMPORTANT: Use POST, not GET! response = self.class.post( url, @@ -221,7 +221,7 @@ class Provider::Traderepublic end Rails.logger.info "TradeRepublic: Refreshing session token" - + # Try the refresh endpoint first response = self.class.post( "#{HOST}/api/v1/auth/refresh", @@ -277,14 +277,14 @@ class Provider::Traderepublic ws.on :message do |msg| Rails.logger.debug "TradeRepublic: WebSocket received message: #{msg.data.to_s.inspect[0..200]}" - + # Mark as connected when we receive the "connected" response if msg.data.start_with?("connected") Rails.logger.info "TradeRepublic: WebSocket confirmed connected" provider.instance_variable_set(:@connected, true) provider.send(:start_echo_thread) end - + provider.send(:handle_websocket_message, msg.data) end @@ -368,7 +368,7 @@ class Provider::Traderepublic timeout = Time.now + SESSION_VALIDATION_TIMEOUT while result.nil? && Time.now < timeout sleep 0.1 - + # Check if an error was stored in the subscription subscription = nil @mutex.synchronize do @@ -387,7 +387,7 @@ class Provider::Traderepublic if result parsed = JSON.parse(result) - + # Handle double-encoded JSON (some TR responses are JSON strings containing JSON) if parsed.is_a?(String) && (parsed.start_with?("{") || parsed.start_with?("[")) begin @@ -537,176 +537,176 @@ class Provider::Traderepublic private - def default_headers - { - "Content-Type" => "application/json", - "Accept" => "application/json", - "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", - "Origin" => "https://app.traderepublic.com", - "Referer" => "https://app.traderepublic.com/", - "Accept-Language" => "en", - "x-tr-platform" => "web", - "x-tr-app-version" => "12.12.0" - } - end - - def cookie_header - return {} if @raw_cookies.nil? || @raw_cookies.empty? - - # Join all cookies into a single Cookie header - cookie_string = @raw_cookies.map do |cookie| - # Extract just the name=value part before the first semicolon - cookie.split(";").first - end.join("; ") - - { "Cookie" => cookie_string } - end - - def extract_cookies_from_response(response) - # Extract Set-Cookie headers - set_cookie_headers = response.headers["set-cookie"] - - if set_cookie_headers - @raw_cookies = set_cookie_headers.is_a?(Array) ? set_cookie_headers : [ set_cookie_headers ] - - # Extract session and refresh tokens - @session_token = extract_cookie_value("tr_session") - @refresh_token = extract_cookie_value("tr_refresh") - end - end - - def extract_cookie_value(name) - @raw_cookies.each do |cookie| - match = cookie.match(/#{name}=([^;]+)/) - return match[1] if match - end - nil - end - - def wait_for_connection - timeout = Time.now + WS_CONNECTION_TIMEOUT - until @connected || Time.now > timeout - sleep 0.1 + def default_headers + { + "Content-Type" => "application/json", + "Accept" => "application/json", + "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "Origin" => "https://app.traderepublic.com", + "Referer" => "https://app.traderepublic.com/", + "Accept-Language" => "en", + "x-tr-platform" => "web", + "x-tr-app-version" => "12.12.0" + } end - raise TraderepublicError.new("WebSocket connection timeout", :connection_timeout) unless @connected - end + def cookie_header + return {} if @raw_cookies.nil? || @raw_cookies.empty? - def start_echo_thread - @echo_thread = Thread.new do - loop do - sleep ECHO_INTERVAL - break unless @connected - send_echo + # Join all cookies into a single Cookie header + cookie_string = @raw_cookies.map do |cookie| + # Extract just the name=value part before the first semicolon + cookie.split(";").first + end.join("; ") + + { "Cookie" => cookie_string } + end + + def extract_cookies_from_response(response) + # Extract Set-Cookie headers + set_cookie_headers = response.headers["set-cookie"] + + if set_cookie_headers + @raw_cookies = set_cookie_headers.is_a?(Array) ? set_cookie_headers : [ set_cookie_headers ] + + # Extract session and refresh tokens + @session_token = extract_cookie_value("tr_session") + @refresh_token = extract_cookie_value("tr_refresh") end end - end - def send_echo - @ws&.send("echo #{Time.now.to_i * 1000}") - rescue => e - Rails.logger.warn "TradeRepublic: Failed to send echo - #{e.message}" - end - - def handle_websocket_message(raw_message) - return if raw_message.start_with?("echo") || raw_message.start_with?("connected") - - parsed = parse_websocket_payload(raw_message) - return unless parsed - - sub_id = parsed[:subscription_id] - json_string = parsed[:json_data] - - begin - data = JSON.parse(json_string) - rescue JSON::ParserError - Rails.logger.error "TradeRepublic: Failed to parse WebSocket message JSON" - return + def extract_cookie_value(name) + @raw_cookies.each do |cookie| + match = cookie.match(/#{name}=([^;]+)/) + return match[1] if match + end + nil end - # Check for authentication errors - if data.is_a?(Hash) && data["errors"] - auth_error = data["errors"].find { |err| err["errorCode"] == "AUTHENTICATION_ERROR" } - if auth_error - Rails.logger.error "TradeRepublic: Authentication error received - #{auth_error['errorMessage']}" - # Store error for the subscription callback - if sub_id && @subscriptions[sub_id] - @subscriptions[sub_id][:error] = TraderepublicError.new(auth_error["errorMessage"] || "Unauthorized", :auth_failed) + def wait_for_connection + timeout = Time.now + WS_CONNECTION_TIMEOUT + until @connected || Time.now > timeout + sleep 0.1 + end + + raise TraderepublicError.new("WebSocket connection timeout", :connection_timeout) unless @connected + end + + def start_echo_thread + @echo_thread = Thread.new do + loop do + sleep ECHO_INTERVAL + break unless @connected + send_echo end end end - return unless sub_id + def send_echo + @ws&.send("echo #{Time.now.to_i * 1000}") + rescue => e + Rails.logger.warn "TradeRepublic: Failed to send echo - #{e.message}" + end + + def handle_websocket_message(raw_message) + return if raw_message.start_with?("echo") || raw_message.start_with?("connected") + + parsed = parse_websocket_payload(raw_message) + return unless parsed + + sub_id = parsed[:subscription_id] + json_string = parsed[:json_data] - subscription = @subscriptions[sub_id] - if subscription begin - # If there's an error stored, raise it - raise subscription[:error] if subscription[:error] - - subscription[:callback].call(json_string) - rescue => e - Rails.logger.error "TradeRepublic: Subscription callback error - #{e.message}" - raise if e.is_a?(TraderepublicError) # Re-raise TraderepublicError to propagate auth failures + data = JSON.parse(json_string) + rescue JSON::ParserError + Rails.logger.error "TradeRepublic: Failed to parse WebSocket message JSON" + return + end + + # Check for authentication errors + if data.is_a?(Hash) && data["errors"] + auth_error = data["errors"].find { |err| err["errorCode"] == "AUTHENTICATION_ERROR" } + if auth_error + Rails.logger.error "TradeRepublic: Authentication error received - #{auth_error['errorMessage']}" + # Store error for the subscription callback + if sub_id && @subscriptions[sub_id] + @subscriptions[sub_id][:error] = TraderepublicError.new(auth_error["errorMessage"] || "Unauthorized", :auth_failed) + end + end + end + + return unless sub_id + + subscription = @subscriptions[sub_id] + if subscription + begin + # If there's an error stored, raise it + raise subscription[:error] if subscription[:error] + + subscription[:callback].call(json_string) + rescue => e + Rails.logger.error "TradeRepublic: Subscription callback error - #{e.message}" + raise if e.is_a?(TraderepublicError) # Re-raise TraderepublicError to propagate auth failures + end end end - end - def parse_websocket_payload(raw_message) - # Find the first occurrence of { or [ - start_index_obj = raw_message.index("{") - start_index_arr = raw_message.index("[") - - start_index = if start_index_obj && start_index_arr - [start_index_obj, start_index_arr].min - elsif start_index_obj - start_index_obj - elsif start_index_arr - start_index_arr - else - nil - end + def parse_websocket_payload(raw_message) + # Find the first occurrence of { or [ + start_index_obj = raw_message.index("{") + start_index_arr = raw_message.index("[") - return nil unless start_index + start_index = if start_index_obj && start_index_arr + [ start_index_obj, start_index_arr ].min + elsif start_index_obj + start_index_obj + elsif start_index_arr + start_index_arr + else + nil + end - id_part = raw_message[0...start_index].strip - id_match = id_part.match(/\d+/) - subscription_id = id_match ? id_match[0].to_i : nil + return nil unless start_index - json_data = raw_message[start_index..-1].strip + id_part = raw_message[0...start_index].strip + id_match = id_part.match(/\d+/) + subscription_id = id_match ? id_match[0].to_i : nil - { subscription_id: subscription_id, json_data: json_data } - end + json_data = raw_message[start_index..-1].strip - def build_message(type, params = {}) - { type: type, token: @session_token }.merge(params) - end - - def send_subscription(sub_id, message) - payload = "sub #{sub_id} #{message.to_json}" - @ws.send(payload) - end - - def handle_http_response(response) - Rails.logger.error "TradeRepublic: HTTP response code=#{response.code}, body=#{response.body}" - - case response.code - when 200 - JSON.parse(response.body) - when 400 - raise TraderepublicError.new("Bad request: #{response.body}", :bad_request) - when 401 - raise TraderepublicError.new("Invalid credentials", :unauthorized) - when 403 - raise TraderepublicError.new("Access forbidden", :forbidden) - when 404 - raise TraderepublicError.new("Resource not found", :not_found) - when 429 - raise TraderepublicError.new("Rate limit exceeded", :rate_limit_exceeded) - when 500..599 - raise TraderepublicError.new("Server error: #{response.code}", :server_error) - else - raise TraderepublicError.new("Unexpected response: #{response.code}", :unexpected_response) + { subscription_id: subscription_id, json_data: json_data } + end + + def build_message(type, params = {}) + { type: type, token: @session_token }.merge(params) + end + + def send_subscription(sub_id, message) + payload = "sub #{sub_id} #{message.to_json}" + @ws.send(payload) + end + + def handle_http_response(response) + Rails.logger.error "TradeRepublic: HTTP response code=#{response.code}, body=#{response.body}" + + case response.code + when 200 + JSON.parse(response.body) + when 400 + raise TraderepublicError.new("Bad request: #{response.body}", :bad_request) + when 401 + raise TraderepublicError.new("Invalid credentials", :unauthorized) + when 403 + raise TraderepublicError.new("Access forbidden", :forbidden) + when 404 + raise TraderepublicError.new("Resource not found", :not_found) + when 429 + raise TraderepublicError.new("Rate limit exceeded", :rate_limit_exceeded) + when 500..599 + raise TraderepublicError.new("Server error: #{response.code}", :server_error) + else + raise TraderepublicError.new("Unexpected response: #{response.code}", :unexpected_response) + end end - end end diff --git a/app/models/provider/traderepublic_adapter.rb b/app/models/provider/traderepublic_adapter.rb index 5114ba5e8..385ed72db 100644 --- a/app/models/provider/traderepublic_adapter.rb +++ b/app/models/provider/traderepublic_adapter.rb @@ -83,7 +83,7 @@ class Provider::TraderepublicAdapter < Provider::Base private - def provider_account - @provider_account ||= TraderepublicAccount.find(@account_provider.provider_id) - end + def provider_account + @provider_account ||= TraderepublicAccount.find(@account_provider.provider_id) + end end diff --git a/app/models/traderepublic_account/processor.rb b/app/models/traderepublic_account/processor.rb index c6534eb7f..9a43c8180 100644 --- a/app/models/traderepublic_account/processor.rb +++ b/app/models/traderepublic_account/processor.rb @@ -48,163 +48,164 @@ class TraderepublicAccount::Processor private - def process_transactions(account) - transactions_data = traderepublic_account.raw_transactions_payload - return unless transactions_data + def process_transactions(account) + transactions_data = traderepublic_account.raw_transactions_payload + return unless transactions_data - Rails.logger.info "[TR Processor] transactions_data loaded: #{transactions_data.class}" + Rails.logger.info "[TR Processor] transactions_data loaded: #{transactions_data.class}" - # Extract items array from the payload structure - # Try both Hash and Array formats - items = if transactions_data.is_a?(Hash) - transactions_data["items"] - elsif transactions_data.is_a?(Array) - transactions_data.find { |pair| pair[0] == "items" }&.last - end - - return unless items.is_a?(Array) + # Extract items array from the payload structure + # Try both Hash and Array formats + items = if transactions_data.is_a?(Hash) + transactions_data["items"] + elsif transactions_data.is_a?(Array) + transactions_data.find { |pair| pair[0] == "items" }&.last + end - Rails.logger.info "[TR Processor] items array size: #{items.size}" + return unless items.is_a?(Array) - Rails.logger.info "TraderepublicAccount::Processor - Processing #{items.size} transactions" + Rails.logger.info "[TR Processor] items array size: #{items.size}" - items.each do |txn| - Rails.logger.info "[TR Processor] Processing txn id=#{txn['id']}" - process_single_transaction(account, txn) + Rails.logger.info "TraderepublicAccount::Processor - Processing #{items.size} transactions" + + items.each do |txn| + Rails.logger.info "[TR Processor] Processing txn id=#{txn['id']}" + process_single_transaction(account, txn) + end + + Rails.logger.info "TraderepublicAccount::Processor - Finished processing transactions" end - Rails.logger.info "TraderepublicAccount::Processor - Finished processing transactions" - end + def process_single_transaction(account, txn) + # Skip if deleted or hidden + if txn["deleted"] + Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (deleted)" + return + end + if txn["hidden"] + Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (hidden)" + return + end + unless txn["status"] == "EXECUTED" + Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (status=#{txn['status']})" + return + end - def process_single_transaction(account, txn) - # Skip if deleted or hidden - if txn["deleted"] - Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (deleted)" - return - end - if txn["hidden"] - Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (hidden)" - return - end - unless txn["status"] == "EXECUTED" - Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (status=#{txn['status']})" - return + # Parse basic data + traderepublic_id = txn["id"] + title = txn["title"] + subtitle = txn["subtitle"] + amount_data = txn["amount"] || {} + amount = amount_data["value"] + currency = amount_data["currency"] || "EUR" + timestamp = txn["timestamp"] + + unless traderepublic_id && timestamp && amount + Rails.logger.info "[TR Processor] Skipping txn: missing traderepublic_id, timestamp, or amount (id=#{txn['id']})" + return + end + + # Trade Republic sends negative values for expenses (Buys) and positive values for income (Sells). + # Sure expects negative = income and positive = expense, so we invert the sign here. + amount = -amount.to_f + + # Parse date + begin + date = Time.parse(timestamp).to_date + rescue StandardError => e + Rails.logger.warn "TraderepublicAccount::Processor - Failed to parse timestamp #{timestamp.inspect} for txn #{traderepublic_id}: #{e.class}: #{e.message}. Falling back to Date.today" + date = Date.today + end + + # Check if this is a trade (Buy/Sell Order) + # Note: subtitle contains the trade type info that becomes 'notes' after import + is_trade_result = is_trade?(subtitle) + + Rails.logger.info "TradeRepublic: Processing '#{title}' | Subtitle: '#{subtitle}' | is_trade?: #{is_trade_result}" + + if is_trade_result + Rails.logger.info "[TR Processor] Transaction id=#{traderepublic_id} is a trade." + process_trade(traderepublic_id, title, subtitle, amount, currency, date, txn) + else + Rails.logger.info "[TR Processor] Transaction id=#{traderepublic_id} is NOT a trade. Importing as cash transaction." + # Import cash transactions (dividends, interest, transfers) + import_adapter.import_transaction( + external_id: traderepublic_id, + amount: amount, + currency: currency, + date: date, + name: title, + source: "traderepublic", + notes: subtitle + ) + end + + Rails.logger.info "TraderepublicAccount::Processor - Imported: #{title} (#{subtitle}) - #{amount} #{currency}" + rescue => e + Rails.logger.error "TraderepublicAccount::Processor - Error processing transaction #{txn['id']}: #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") end - # Parse basic data - traderepublic_id = txn["id"] - title = txn["title"] - subtitle = txn["subtitle"] - amount_data = txn["amount"] || {} - amount = amount_data["value"] - currency = amount_data["currency"] || "EUR" - timestamp = txn["timestamp"] - - unless traderepublic_id && timestamp && amount - Rails.logger.info "[TR Processor] Skipping txn: missing traderepublic_id, timestamp, or amount (id=#{txn['id']})" - return + def is_trade?(text) + return false unless text + text_lower = text.downcase + # Support multiple languages and variations + # Manual orders: + # French: Ordre d'achat, Ordre de vente, Ordre d'achat sur stop + # English: Buy order, Sell order + # German: Kauforder, Verkaufsorder + # Savings plans (automatic recurring purchases): + # French: Plan d'épargne exécuté + # English: Savings plan executed + # German: Sparplan ausgeführt + text_lower.match?(/ordre d'achat|ordre de vente|buy order|sell order|kauforder|verkaufsorder|plan d'épargne exécuté|savings plan executed|sparplan ausgeführt/) end - # Trade Republic sends negative values for expenses (Buys) and positive values for income (Sells). - # Sure expects negative = income and positive = expense, so we invert the sign here. - amount = -amount.to_f + def process_trade(external_id, title, subtitle, amount, currency, date, txn) + # Extraire ISIN depuis l'icon (toujours présent) + isin = extract_isin(txn["icon"]) + Rails.logger.info "[TR Processor] process_trade: extracted ISIN=#{isin.inspect} from icon for txn id=#{external_id}" - # Parse date - begin - date = Time.parse(timestamp).to_date - rescue StandardError => e - Rails.logger.warn "TraderepublicAccount::Processor - Failed to parse timestamp #{timestamp.inspect} for txn #{traderepublic_id}: #{e.class}: #{e.message}. Falling back to Date.today" - date = Date.today - end + # 1. Chercher dans trade_details (détail transaction) + trade_details = txn["trade_details"] || {} + quantity_str = nil + price_str = nil + isin_str = nil - # Check if this is a trade (Buy/Sell Order) - # Note: subtitle contains the trade type info that becomes 'notes' after import - is_trade_result = is_trade?(subtitle) - - Rails.logger.info "TradeRepublic: Processing '#{title}' | Subtitle: '#{subtitle}' | is_trade?: #{is_trade_result}" - - if is_trade_result - Rails.logger.info "[TR Processor] Transaction id=#{traderepublic_id} is a trade." - process_trade(traderepublic_id, title, subtitle, amount, currency, date, txn) - else - Rails.logger.info "[TR Processor] Transaction id=#{traderepublic_id} is NOT a trade. Importing as cash transaction." - # Import cash transactions (dividends, interest, transfers) - import_adapter.import_transaction( - external_id: traderepublic_id, - amount: amount, - currency: currency, - date: date, - name: title, - source: "traderepublic", - notes: subtitle - ) - end - - Rails.logger.info "TraderepublicAccount::Processor - Imported: #{title} (#{subtitle}) - #{amount} #{currency}" - rescue => e - Rails.logger.error "TraderepublicAccount::Processor - Error processing transaction #{txn['id']}: #{e.message}" - Rails.logger.error e.backtrace.first(5).join("\n") - end - - def is_trade?(text) - return false unless text - text_lower = text.downcase - # Support multiple languages and variations - # Manual orders: - # French: Ordre d'achat, Ordre de vente, Ordre d'achat sur stop - # English: Buy order, Sell order - # German: Kauforder, Verkaufsorder - # Savings plans (automatic recurring purchases): - # French: Plan d'épargne exécuté - # English: Savings plan executed - # German: Sparplan ausgeführt - text_lower.match?(/ordre d'achat|ordre de vente|buy order|sell order|kauforder|verkaufsorder|plan d'épargne exécuté|savings plan executed|sparplan ausgeführt/) - end - - def process_trade(external_id, title, subtitle, amount, currency, date, txn) - # Extraire ISIN depuis l'icon (toujours présent) - isin = extract_isin(txn["icon"]) - Rails.logger.info "[TR Processor] process_trade: extracted ISIN=#{isin.inspect} from icon for txn id=#{external_id}" - - # 1. Chercher dans trade_details (détail transaction) - trade_details = txn["trade_details"] || {} - quantity_str = nil - price_str = nil - isin_str = nil - - # Extraction robuste depuis trade_details['sections'] (niveau 1 et imbriqué) - if trade_details.is_a?(Hash) && trade_details["sections"].is_a?(Array) - trade_details["sections"].each do |section| - # Cas direct (niveau 1, Transaction) - if section["type"] == "table" && section["title"] == "Transaction" && section["data"].is_a?(Array) - section["data"].each do |row| - case row["title"] - when "Titres", "Actions" - quantity_str ||= row.dig("detail", "text") - when "Cours du titre", "Prix du titre" - price_str ||= row.dig("detail", "text") + # Extraction robuste depuis trade_details['sections'] (niveau 1 et imbriqué) + if trade_details.is_a?(Hash) && trade_details["sections"].is_a?(Array) + trade_details["sections"].each do |section| + # Cas direct (niveau 1, Transaction) + if section["type"] == "table" && section["title"] == "Transaction" && section["data"].is_a?(Array) + section["data"].each do |row| + case row["title"] + when "Titres", "Actions" + quantity_str ||= row.dig("detail", "text") + when "Cours du titre", "Prix du titre" + price_str ||= row.dig("detail", "text") + end end end - end - # Cas direct (niveau 1, tout table) - if section["type"] == "table" && section["data"].is_a?(Array) - section["data"].each do |row| - case row["title"] - when "Actions" - quantity_str ||= row.dig("detail", "text") - when "Prix du titre" - price_str ||= row.dig("detail", "text") - end - # Cas imbriqué : row["title"] == "Transaction" && row["detail"]["action"]["payload"]["sections"] - if row["title"] == "Transaction" && row.dig("detail", "action", "payload", "sections").is_a?(Array) - row["detail"]["action"]["payload"]["sections"].each do |sub_section| - next unless sub_section["type"] == "table" && sub_section["data"].is_a?(Array) - sub_section["data"].each do |sub_row| - case sub_row["title"] - when "Actions", "Titres" - quantity_str ||= sub_row.dig("detail", "text") - when "Prix du titre", "Cours du titre" - price_str ||= sub_row.dig("detail", "text") + # Cas direct (niveau 1, tout table) + if section["type"] == "table" && section["data"].is_a?(Array) + section["data"].each do |row| + case row["title"] + when "Actions" + quantity_str ||= row.dig("detail", "text") + when "Prix du titre" + price_str ||= row.dig("detail", "text") + end + # Cas imbriqué : row["title"] == "Transaction" && row["detail"]["action"]["payload"]["sections"] + if row["title"] == "Transaction" && row.dig("detail", "action", "payload", "sections").is_a?(Array) + row["detail"]["action"]["payload"]["sections"].each do |sub_section| + next unless sub_section["type"] == "table" && sub_section["data"].is_a?(Array) + sub_section["data"].each do |sub_row| + case sub_row["title"] + when "Actions", "Titres" + quantity_str ||= sub_row.dig("detail", "text") + when "Prix du titre", "Cours du titre" + price_str ||= sub_row.dig("detail", "text") + end end end end @@ -212,58 +213,98 @@ class TraderepublicAccount::Processor end end end - end - # Fallback : champs directs - quantity_str ||= txn["quantity"] || txn["qty"] - price_str ||= txn["price"] || txn["price_per_unit"] + # Fallback : champs directs + quantity_str ||= txn["quantity"] || txn["qty"] + price_str ||= txn["price"] || txn["price_per_unit"] - # ISIN : on garde la logique précédente - isin_str = nil - if trade_details.is_a?(Hash) && trade_details["sections"].is_a?(Array) - trade_details["sections"].each do |section| - if section["data"].is_a?(Hash) && section["data"]["icon"] - possible_isin = extract_isin(section["data"]["icon"]) - isin_str ||= possible_isin if possible_isin + # ISIN : on garde la logique précédente + isin_str = nil + if trade_details.is_a?(Hash) && trade_details["sections"].is_a?(Array) + trade_details["sections"].each do |section| + if section["data"].is_a?(Hash) && section["data"]["icon"] + possible_isin = extract_isin(section["data"]["icon"]) + isin_str ||= possible_isin if possible_isin + end end end - end - isin = isin_str if isin_str.present? + isin = isin_str if isin_str.present? - Rails.logger.info "TradeRepublic: Processing trade #{title}" - Rails.logger.info "TradeRepublic: Values - Qty: #{quantity_str}, Price: #{price_str}, ISIN: #{isin_str || isin}" - Rails.logger.info "[TR Processor] process_trade: after details, ISIN=#{isin.inspect}, quantity_str=#{quantity_str.inspect}, price_str=#{price_str.inspect}" + Rails.logger.info "TradeRepublic: Processing trade #{title}" + Rails.logger.info "TradeRepublic: Values - Qty: #{quantity_str}, Price: #{price_str}, ISIN: #{isin_str || isin}" + Rails.logger.info "[TR Processor] process_trade: after details, ISIN=#{isin.inspect}, quantity_str=#{quantity_str.inspect}, price_str=#{price_str.inspect}" - # Correction : s'assurer que le subtitle utilisé est bien celui du trade (issu de txn["subtitle"] si besoin) - effective_subtitle = subtitle.presence || txn["subtitle"] - # Détermine le type d'opération (buy/sell) - op_type = nil - if effective_subtitle.to_s.downcase.match?(/sell|vente|verkauf/) - op_type = "sell" - elsif effective_subtitle.to_s.downcase.match?(/buy|achat|kauf/) - op_type = "buy" - end - - quantity = parse_quantity(quantity_str) if quantity_str - quantity = -quantity if quantity && op_type == "sell" - price = parse_price(price_str) if price_str - - # Extract ticker and mic from instrument_details if available - instrument_data = txn["instrument_details"] - ticker = nil - mic = nil - if instrument_data.present? - ticker_mic_pairs = extract_ticker_and_mic(instrument_data, isin) - if ticker_mic_pairs.any? - ticker, mic = ticker_mic_pairs.first + # Correction : s'assurer que le subtitle utilisé est bien celui du trade (issu de txn["subtitle"] si besoin) + effective_subtitle = subtitle.presence || txn["subtitle"] + # Détermine le type d'opération (buy/sell) + op_type = nil + if effective_subtitle.to_s.downcase.match?(/sell|vente|verkauf/) + op_type = "sell" + elsif effective_subtitle.to_s.downcase.match?(/buy|achat|kauf/) + op_type = "buy" end - end - # Si on n'a pas de quantité ou de prix, fallback transaction simple - if isin && quantity.nil? && amount && amount != 0 - Rails.logger.warn "TradeRepublic: Cannot extract quantity/price for trade #{external_id} (#{title})" - Rails.logger.warn "TradeRepublic: Importing as transaction instead of trade" - Rails.logger.info "[TR Processor] process_trade: skipping trade creation for txn id=#{external_id} (missing quantity or price)" + quantity = parse_quantity(quantity_str) if quantity_str + quantity = -quantity if quantity && op_type == "sell" + price = parse_price(price_str) if price_str + + # Extract ticker and mic from instrument_details if available + instrument_data = txn["instrument_details"] + ticker = nil + mic = nil + if instrument_data.present? + ticker_mic_pairs = extract_ticker_and_mic(instrument_data, isin) + if ticker_mic_pairs.any? + ticker, mic = ticker_mic_pairs.first + end + end + + # Si on n'a pas de quantité ou de prix, fallback transaction simple + if isin && quantity.nil? && amount && amount != 0 + Rails.logger.warn "TradeRepublic: Cannot extract quantity/price for trade #{external_id} (#{title})" + Rails.logger.warn "TradeRepublic: Importing as transaction instead of trade" + Rails.logger.info "[TR Processor] process_trade: skipping trade creation for txn id=#{external_id} (missing quantity or price)" + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: title, + source: "traderepublic", + notes: subtitle + ) + return + end + + # Créer le trade si toutes les infos sont là + if isin && quantity && price + Rails.logger.info "[TR Processor] process_trade: ready to call find_or_create_security for ISIN=#{isin.inspect}, title=#{title.inspect}, ticker=#{ticker.inspect}, mic=#{mic.inspect}" + security = find_or_create_security(isin, title, ticker, mic) + if security + Rails.logger.info "[TR Processor] process_trade: got security id=#{security.id} for ISIN=#{isin}" + Rails.logger.info "[TR Processor] TRADE IMPORT: external_id=#{external_id} qty=#{quantity} security_id=#{security.id} isin=#{isin} ticker=#{ticker} mic=#{mic} op_type=#{op_type}" + import_adapter.import_trade( + external_id: external_id, + security: security, + quantity: quantity, + price: price, + amount: amount, + currency: currency, + date: date, + name: "#{title} - #{subtitle}", + source: "traderepublic", + trade_type: op_type + ) + return + else + Rails.logger.error "[TR Processor] process_trade: find_or_create_security returned nil for ISIN=#{isin}" + Rails.logger.error "TradeRepublic: Could not create security for ISIN #{isin}" + end + end + + # Fallback : transaction simple + Rails.logger.warn "TradeRepublic: Falling back to transaction for #{external_id}: ISIN=#{isin}, Qty=#{quantity}, Price=#{price}" + Rails.logger.info "[TR Processor] process_trade: fallback to cash transaction for txn id=#{external_id}" import_adapter.import_transaction( external_id: external_id, amount: amount, @@ -273,321 +314,280 @@ class TraderepublicAccount::Processor source: "traderepublic", notes: subtitle ) - return end - # Créer le trade si toutes les infos sont là - if isin && quantity && price - Rails.logger.info "[TR Processor] process_trade: ready to call find_or_create_security for ISIN=#{isin.inspect}, title=#{title.inspect}, ticker=#{ticker.inspect}, mic=#{mic.inspect}" - security = find_or_create_security(isin, title, ticker, mic) - if security - Rails.logger.info "[TR Processor] process_trade: got security id=#{security.id} for ISIN=#{isin}" - Rails.logger.info "[TR Processor] TRADE IMPORT: external_id=#{external_id} qty=#{quantity} security_id=#{security.id} isin=#{isin} ticker=#{ticker} mic=#{mic} op_type=#{op_type}" - import_adapter.import_trade( - external_id: external_id, - security: security, - quantity: quantity, - price: price, - amount: amount, - currency: currency, - date: date, - name: "#{title} - #{subtitle}", - source: "traderepublic", - trade_type: op_type - ) - return - else - Rails.logger.error "[TR Processor] process_trade: find_or_create_security returned nil for ISIN=#{isin}" - Rails.logger.error "TradeRepublic: Could not create security for ISIN #{isin}" + + def extract_all_data(obj, result = {}) + case obj + when Hash + # Check if this hash looks like a data item with title/detail + if obj["title"] && obj["detail"] && obj["detail"].is_a?(Hash) && obj["detail"]["text"] + result[obj["title"]] = obj["detail"]["text"] + end + + # Recursively process all values + obj.each do |key, value| + extract_all_data(value, result) + end + when Array + obj.each do |item| + extract_all_data(item, result) + end end + result end - # Fallback : transaction simple - Rails.logger.warn "TradeRepublic: Falling back to transaction for #{external_id}: ISIN=#{isin}, Qty=#{quantity}, Price=#{price}" - Rails.logger.info "[TR Processor] process_trade: fallback to cash transaction for txn id=#{external_id}" - import_adapter.import_transaction( - external_id: external_id, - amount: amount, - currency: currency, - date: date, - name: title, - source: "traderepublic", - notes: subtitle - ) - end + def parse_quantity(quantity_str) + # quantity_str format: "3 Shares" or "0.01 BTC" + return nil unless quantity_str + token = quantity_str.to_s.split.first + cleaned = token.to_s.gsub(/[^0-9.,\-+]/, "") + return nil if cleaned.blank? - def extract_all_data(obj, result = {}) - case obj - when Hash - # Check if this hash looks like a data item with title/detail - if obj["title"] && obj["detail"] && obj["detail"].is_a?(Hash) && obj["detail"]["text"] - result[obj["title"]] = obj["detail"]["text"] - end - - # Recursively process all values - obj.each do |key, value| - extract_all_data(value, result) - end - when Array - obj.each do |item| - extract_all_data(item, result) - end - end - result - end - - def parse_quantity(quantity_str) - # quantity_str format: "3 Shares" or "0.01 BTC" - return nil unless quantity_str - - token = quantity_str.to_s.split.first - cleaned = token.to_s.gsub(/[^0-9.,\-+]/, "") - return nil if cleaned.blank? - - begin - Float(cleaned.tr(",", ".")).abs - rescue ArgumentError, TypeError - nil - end - end - - def parse_price(price_str) - # price_str format: "€166.70" or "$500.00" - extract numeric substring and parse strictly - return nil unless price_str - - match = price_str.to_s.match(/[+\-]?\d+(?:[.,]\d+)*/) - return nil unless match - - cleaned = match[0].tr(",", ".") - begin - Float(cleaned) - rescue ArgumentError, TypeError - nil - end - end - - def extract_isin(isin_or_icon) - return nil unless isin_or_icon - - # If it's already an ISIN (12 characters) - return isin_or_icon if isin_or_icon.match?(/^[A-Z]{2}[A-Z0-9]{9}\d$/) - - # Extract from icon path: "logos/US0378331005/v2" - match = isin_or_icon.match(%r{logos/([A-Z]{2}[A-Z0-9]{9}\d)/}) - match ? match[1] : nil - end - - def find_or_create_security(isin, fallback_name = nil, ticker = nil, mic = nil) - # Always use string and upcase safely - safe_isin = isin.to_s.upcase - safe_ticker = ticker.to_s.upcase if ticker - safe_mic = mic.to_s.upcase if mic - resolved = TradeRepublic::SecurityResolver.new(safe_isin, name: fallback_name, ticker: safe_ticker, mic: safe_mic).resolve - return resolved if resolved - Rails.logger.error "TradeRepublic: SecurityResolver n'a pas pu trouver ou créer de security pour ISIN=#{safe_isin}, name=#{fallback_name}, ticker=#{safe_ticker}, mic=#{safe_mic}" - nil - end - - # fetch_trade_details et fetch_instrument_details supprimés : tout est lu depuis raw_transactions_payload - - def extract_security_name(instrument_data) - return nil unless instrument_data.is_a?(Hash) - - # Trade Republic returns instrument details with the name in different possible locations: - # 1. Direct name field - # 2. First exchange's nameAtExchange (most common for stocks/ETFs) - # 3. shortName or typeNameAtExchange for other instruments - - # Try direct name fields first - name = instrument_data["name"] || - instrument_data["shortName"] || - instrument_data["typeNameAtExchange"] - - # If no direct name, try getting from first active exchange - if name.blank? && instrument_data["exchanges"].is_a?(Array) - active_exchange = instrument_data["exchanges"].find { |ex| ex["active"] == true } - exchange = active_exchange || instrument_data["exchanges"].first - name = exchange["nameAtExchange"] if exchange - end - - name&.strip - end - - # Returns an Array of [ticker, mic] pairs ordered by relevance (active exchanges first) - def extract_ticker_and_mic(instrument_data, isin) - return [[isin, nil]] unless instrument_data.is_a?(Hash) - - exchanges = instrument_data["exchanges"] - return [[isin, nil]] unless exchanges.is_a?(Array) && exchanges.any? - - # Order exchanges by active first, then the rest in their provided order - ordered = exchanges.partition { |ex| ex["active"] == true }.flatten - - pairs = ordered.map do |ex| - ticker = ex["symbolAtExchange"] || ex["symbol"] - mic = ex["slug"] || ex["mic"] || ex["mic_code"] - ticker = isin if ticker.blank? - ticker = clean_ticker(ticker) - [ticker, mic] - end - - # Remove duplicates while preserving order - pairs.map { |t, m| [t, m] }.uniq - end - - def clean_ticker(ticker) - return ticker unless ticker - - # Remove common suffixes - # Examples: "AAPL.US" -> "AAPL", "BTCEUR.SPOT" -> "BTC/EUR" (keep as is for crypto) - cleaned = ticker.strip - - # Don't clean if it looks like a crypto pair (contains /) - return cleaned if cleaned.include?("/") - - # Remove .SPOT, .US, etc. - cleaned = cleaned.split(".").first if cleaned.include?(".") - - cleaned - end - - def process_holdings(account) - payload = traderepublic_account.raw_payload - return unless payload.is_a?(Hash) - - # The payload is wrapped in a 'raw' key by the Importer - portfolio_data = payload["raw"] || payload - - positions = extract_positions(portfolio_data) - - if positions.empty? - Rails.logger.info "TraderepublicAccount::Processor - No positions found in payload." - Rails.logger.info "TraderepublicAccount::Processor - Calculating holdings from trades..." - - # Calculate holdings from trades using ForwardCalculator begin - calculated_holdings = Holding::ForwardCalculator.new(account).calculate - # Importer tous les holdings calculés, y compris qty = 0 (pour refléter la fermeture de position) - if calculated_holdings.any? - Holding.import!(calculated_holdings, on_duplicate_key_update: { - conflict_target: [ :account_id, :security_id, :date, :currency ], - columns: [ :qty, :price, :amount, :updated_at ] - }) - Rails.logger.info "TraderepublicAccount::Processor - Saved #{calculated_holdings.size} calculated holdings (no filter)" - else - Rails.logger.info "TraderepublicAccount::Processor - No holdings calculated from trades" + Float(cleaned.tr(",", ".")).abs + rescue ArgumentError, TypeError + nil + end + end + + def parse_price(price_str) + # price_str format: "€166.70" or "$500.00" - extract numeric substring and parse strictly + return nil unless price_str + + match = price_str.to_s.match(/[+\-]?\d+(?:[.,]\d+)*/) + return nil unless match + + cleaned = match[0].tr(",", ".") + begin + Float(cleaned) + rescue ArgumentError, TypeError + nil + end + end + + def extract_isin(isin_or_icon) + return nil unless isin_or_icon + + # If it's already an ISIN (12 characters) + return isin_or_icon if isin_or_icon.match?(/^[A-Z]{2}[A-Z0-9]{9}\d$/) + + # Extract from icon path: "logos/US0378331005/v2" + match = isin_or_icon.match(%r{logos/([A-Z]{2}[A-Z0-9]{9}\d)/}) + match ? match[1] : nil + end + + def find_or_create_security(isin, fallback_name = nil, ticker = nil, mic = nil) + # Always use string and upcase safely + safe_isin = isin.to_s.upcase + safe_ticker = ticker.to_s.upcase if ticker + safe_mic = mic.to_s.upcase if mic + resolved = TradeRepublic::SecurityResolver.new(safe_isin, name: fallback_name, ticker: safe_ticker, mic: safe_mic).resolve + return resolved if resolved + Rails.logger.error "TradeRepublic: SecurityResolver n'a pas pu trouver ou créer de security pour ISIN=#{safe_isin}, name=#{fallback_name}, ticker=#{safe_ticker}, mic=#{safe_mic}" + nil + end + + # fetch_trade_details et fetch_instrument_details supprimés : tout est lu depuis raw_transactions_payload + + def extract_security_name(instrument_data) + return nil unless instrument_data.is_a?(Hash) + + # Trade Republic returns instrument details with the name in different possible locations: + # 1. Direct name field + # 2. First exchange's nameAtExchange (most common for stocks/ETFs) + # 3. shortName or typeNameAtExchange for other instruments + + # Try direct name fields first + name = instrument_data["name"] || + instrument_data["shortName"] || + instrument_data["typeNameAtExchange"] + + # If no direct name, try getting from first active exchange + if name.blank? && instrument_data["exchanges"].is_a?(Array) + active_exchange = instrument_data["exchanges"].find { |ex| ex["active"] == true } + exchange = active_exchange || instrument_data["exchanges"].first + name = exchange["nameAtExchange"] if exchange + end + + name&.strip + end + + # Returns an Array of [ticker, mic] pairs ordered by relevance (active exchanges first) + def extract_ticker_and_mic(instrument_data, isin) + return [ [ isin, nil ] ] unless instrument_data.is_a?(Hash) + + exchanges = instrument_data["exchanges"] + return [ [ isin, nil ] ] unless exchanges.is_a?(Array) && exchanges.any? + + # Order exchanges by active first, then the rest in their provided order + ordered = exchanges.partition { |ex| ex["active"] == true }.flatten + + pairs = ordered.map do |ex| + ticker = ex["symbolAtExchange"] || ex["symbol"] + mic = ex["slug"] || ex["mic"] || ex["mic_code"] + ticker = isin if ticker.blank? + ticker = clean_ticker(ticker) + [ ticker, mic ] + end + + # Remove duplicates while preserving order + pairs.map { |t, m| [ t, m ] }.uniq + end + + def clean_ticker(ticker) + return ticker unless ticker + + # Remove common suffixes + # Examples: "AAPL.US" -> "AAPL", "BTCEUR.SPOT" -> "BTC/EUR" (keep as is for crypto) + cleaned = ticker.strip + + # Don't clean if it looks like a crypto pair (contains /) + return cleaned if cleaned.include?("/") + + # Remove .SPOT, .US, etc. + cleaned = cleaned.split(".").first if cleaned.include?(".") + + cleaned + end + + def process_holdings(account) + payload = traderepublic_account.raw_payload + return unless payload.is_a?(Hash) + + # The payload is wrapped in a 'raw' key by the Importer + portfolio_data = payload["raw"] || payload + + positions = extract_positions(portfolio_data) + + if positions.empty? + Rails.logger.info "TraderepublicAccount::Processor - No positions found in payload." + Rails.logger.info "TraderepublicAccount::Processor - Calculating holdings from trades..." + + # Calculate holdings from trades using ForwardCalculator + begin + calculated_holdings = Holding::ForwardCalculator.new(account).calculate + # Importer tous les holdings calculés, y compris qty = 0 (pour refléter la fermeture de position) + if calculated_holdings.any? + Holding.import!(calculated_holdings, on_duplicate_key_update: { + conflict_target: [ :account_id, :security_id, :date, :currency ], + columns: [ :qty, :price, :amount, :updated_at ] + }) + Rails.logger.info "TraderepublicAccount::Processor - Saved #{calculated_holdings.size} calculated holdings (no filter)" + else + Rails.logger.info "TraderepublicAccount::Processor - No holdings calculated from trades" + end + rescue => e + Rails.logger.error "TraderepublicAccount::Processor - Error calculating holdings from trades: #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") end - rescue => e - Rails.logger.error "TraderepublicAccount::Processor - Error calculating holdings from trades: #{e.message}" - Rails.logger.error e.backtrace.first(5).join("\n") + + return end - - return - end - Rails.logger.info "TraderepublicAccount::Processor - Processing #{positions.size} holdings" + Rails.logger.info "TraderepublicAccount::Processor - Processing #{positions.size} holdings" - positions.each do |pos| - process_single_holding(account, pos) - end - end - - def extract_positions(portfolio_data) - return [] unless portfolio_data.is_a?(Hash) - - # Try to find categories in different places - # Sometimes the payload is directly the array of categories? No, usually it's an object. - # But sometimes it's nested in 'payload' - - categories = [] - - if portfolio_data["categories"].is_a?(Array) - categories = portfolio_data["categories"] - elsif portfolio_data.dig("payload", "categories").is_a?(Array) - categories = portfolio_data.dig("payload", "categories") - elsif portfolio_data["payload"].is_a?(Hash) && portfolio_data["payload"]["categories"].is_a?(Array) - categories = portfolio_data["payload"]["categories"] - end - - Rails.logger.info "TraderepublicAccount::Processor - Categories type: #{categories.class}" - if categories.is_a?(Array) - Rails.logger.info "TraderepublicAccount::Processor - Categories count: #{categories.size}" - if categories.empty? - Rails.logger.info "TraderepublicAccount::Processor - Portfolio data keys: #{portfolio_data.keys}" - Rails.logger.info "TraderepublicAccount::Processor - Payload keys: #{portfolio_data['payload'].keys}" if portfolio_data['payload'].is_a?(Hash) + positions.each do |pos| + process_single_holding(account, pos) end - categories.each_with_index do |cat, idx| - Rails.logger.info "TraderepublicAccount::Processor - Category #{idx} keys: #{cat.keys rescue 'not a hash'}" - if cat.is_a?(Hash) && cat["positions"] - Rails.logger.info "TraderepublicAccount::Processor - Category #{idx} positions type: #{cat['positions'].class}" + end + + def extract_positions(portfolio_data) + return [] unless portfolio_data.is_a?(Hash) + + # Try to find categories in different places + # Sometimes the payload is directly the array of categories? No, usually it's an object. + # But sometimes it's nested in 'payload' + + categories = [] + + if portfolio_data["categories"].is_a?(Array) + categories = portfolio_data["categories"] + elsif portfolio_data.dig("payload", "categories").is_a?(Array) + categories = portfolio_data.dig("payload", "categories") + elsif portfolio_data["payload"].is_a?(Hash) && portfolio_data["payload"]["categories"].is_a?(Array) + categories = portfolio_data["payload"]["categories"] + end + + Rails.logger.info "TraderepublicAccount::Processor - Categories type: #{categories.class}" + if categories.is_a?(Array) + Rails.logger.info "TraderepublicAccount::Processor - Categories count: #{categories.size}" + if categories.empty? + Rails.logger.info "TraderepublicAccount::Processor - Portfolio data keys: #{portfolio_data.keys}" + Rails.logger.info "TraderepublicAccount::Processor - Payload keys: #{portfolio_data['payload'].keys}" if portfolio_data["payload"].is_a?(Hash) + end + categories.each_with_index do |cat, idx| + Rails.logger.info "TraderepublicAccount::Processor - Category #{idx} keys: #{cat.keys rescue 'not a hash'}" + if cat.is_a?(Hash) && cat["positions"] + Rails.logger.info "TraderepublicAccount::Processor - Category #{idx} positions type: #{cat['positions'].class}" + end end end + + positions = [] + categories.each do |category| + next unless category["positions"].is_a?(Array) + category["positions"].each { |p| positions << p } + end + positions end - positions = [] - categories.each do |category| - next unless category["positions"].is_a?(Array) - category["positions"].each { |p| positions << p } - end - positions - end + def process_single_holding(account, pos) + isin = pos["isin"] + name = pos["name"] + quantity = pos["netSize"].to_f - def process_single_holding(account, pos) - isin = pos["isin"] - name = pos["name"] - quantity = pos["netSize"].to_f - - # Try to find current value - # Trade Republic usually sends 'netValue' for the total current value of the position - amount = pos["netValue"]&.to_f - - # Cost basis - avg_buy_in = pos["averageBuyIn"]&.to_f - cost_basis = avg_buy_in ? (quantity * avg_buy_in) : nil - - return unless isin && quantity - - if amount.nil? - Rails.logger.warn "TraderepublicAccount::Processor - Holding #{isin} missing netValue. Keys: #{pos.keys}" - return + # Try to find current value + # Trade Republic usually sends 'netValue' for the total current value of the position + amount = pos["netValue"]&.to_f + + # Cost basis + avg_buy_in = pos["averageBuyIn"]&.to_f + cost_basis = avg_buy_in ? (quantity * avg_buy_in) : nil + + return unless isin && quantity + + if amount.nil? + Rails.logger.warn "TraderepublicAccount::Processor - Holding #{isin} missing netValue. Keys: #{pos.keys}" + return + end + + security = find_or_create_security(isin, name) + return unless security + + price = quantity.zero? ? 0 : (amount / quantity) + + # Prefer position currency if present, else fall back to linked account currency or account default, then final fallback to EUR + currency = pos["currency"] || traderepublic_account.linked_account&.currency || traderepublic_account.linked_account&.default_currency || "EUR" + + import_adapter.import_holding( + security: security, + quantity: quantity, + amount: amount, + currency: currency, + date: Date.today, + price: price, + cost_basis: cost_basis, + source: "traderepublic", + external_id: isin, + account_provider_id: traderepublic_account.account_provider&.id + ) + rescue => e + Rails.logger.error "TraderepublicAccount::Processor - Error processing holding #{pos['isin']}: #{e.message}" end - security = find_or_create_security(isin, name) - return unless security - - price = quantity.zero? ? 0 : (amount / quantity) + def update_balance(account) + balance = traderepublic_account.current_balance + return unless balance - # Prefer position currency if present, else fall back to linked account currency or account default, then final fallback to EUR - currency = pos["currency"] || traderepublic_account.linked_account&.currency || traderepublic_account.linked_account&.default_currency || "EUR" + Rails.logger.info "TraderepublicAccount::Processor - Updating balance to #{balance}" - import_adapter.import_holding( - security: security, - quantity: quantity, - amount: amount, - currency: currency, - date: Date.today, - price: price, - cost_basis: cost_basis, - source: "traderepublic", - external_id: isin, - account_provider_id: traderepublic_account.account_provider&.id - ) - rescue => e - Rails.logger.error "TraderepublicAccount::Processor - Error processing holding #{pos['isin']}: #{e.message}" - end + # Update account balance + account.update(balance: balance) + end - def update_balance(account) - balance = traderepublic_account.current_balance - return unless balance - - Rails.logger.info "TraderepublicAccount::Processor - Updating balance to #{balance}" - - # Update account balance - account.update(balance: balance) - end - - def import_adapter - @import_adapter ||= Account::ProviderImportAdapter.new(traderepublic_account.linked_account) - end + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(traderepublic_account.linked_account) + end end diff --git a/app/models/traderepublic_item.rb b/app/models/traderepublic_item.rb index 858b7c78a..eb701b7f7 100644 --- a/app/models/traderepublic_item.rb +++ b/app/models/traderepublic_item.rb @@ -52,13 +52,13 @@ class TraderepublicItem < ApplicationRecord TraderepublicItem::Importer.new(self, traderepublic_provider: provider).import rescue TraderepublicError => e # If authentication failed and we have credentials, try re-authenticating automatically - if [:unauthorized, :auth_failed].include?(e.error_code) && !skip_token_refresh && credentials_configured? + if [ :unauthorized, :auth_failed ].include?(e.error_code) && !skip_token_refresh && credentials_configured? Rails.logger.warn "TraderepublicItem #{id} - Authentication failed, attempting automatic re-authentication" - + if auto_reauthenticate Rails.logger.info "TraderepublicItem #{id} - Re-authentication successful, retrying import" # Retry import with fresh tokens (skip_token_refresh to avoid infinite loop) - return import_latest_traderepublic_data(skip_token_refresh: true) + import_latest_traderepublic_data(skip_token_refresh: true) else Rails.logger.error "TraderepublicItem #{id} - Automatic re-authentication failed" update!(status: :requires_update) @@ -146,7 +146,7 @@ class TraderepublicItem < ApplicationRecord # Trade Republic doesn't support token refresh, so we need to re-authenticate from scratch def auto_reauthenticate Rails.logger.info "TraderepublicItem #{id}: Starting automatic re-authentication" - + unless credentials_configured? Rails.logger.error "TraderepublicItem #{id}: Cannot auto re-authenticate - credentials not configured" return false @@ -155,14 +155,14 @@ class TraderepublicItem < ApplicationRecord begin # Step 1: Initiate login to get processId result = initiate_login! - + Rails.logger.info "TraderepublicItem #{id}: Login initiated, processId: #{process_id}" - + # Trade Republic requires SMS verification - we can't auto-complete this step # Mark as requires_update so user knows they need to re-authenticate Rails.logger.warn "TraderepublicItem #{id}: SMS verification required - automatic re-authentication cannot proceed" update!(status: :requires_update) - + false rescue => e Rails.logger.error "TraderepublicItem #{id}: Automatic re-authentication failed - #{e.message}" @@ -220,18 +220,18 @@ class TraderepublicItem < ApplicationRecord success = importer.import if success sync.complete! - return true + true else sync.fail! sync.update(error: "Import failed") - return false + false end rescue => e sync.fail! sync.update(error: e.message) Rails.logger.error "TraderepublicItem #{id} - perform_sync failed: #{e.class}: #{e.message}" Rails.logger.error e.backtrace.join("\n") - return false + false end end end diff --git a/app/models/traderepublic_item/importer.rb b/app/models/traderepublic_item/importer.rb index 009c42a11..68a51470b 100644 --- a/app/models/traderepublic_item/importer.rb +++ b/app/models/traderepublic_item/importer.rb @@ -19,7 +19,6 @@ class TraderepublicItem::Importer end def import - raise "Provider not configured" unless provider ensure_session_configured! @@ -55,67 +54,61 @@ class TraderepublicItem::Importer private - def import_portfolio - Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching portfolio data" + def import_portfolio + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching portfolio data" - portfolio_data = provider.get_portfolio - cash_data = provider.get_cash - - parsed_portfolio = if portfolio_data - portfolio_data.is_a?(String) ? JSON.parse(portfolio_data) : portfolio_data - else - {} - end - - parsed_cash = if cash_data - cash_data.is_a?(String) ? JSON.parse(cash_data) : cash_data - else - nil - end + portfolio_data = provider.get_portfolio + cash_data = provider.get_cash - # Get or create main account - account = find_or_create_main_account(parsed_portfolio) + parsed_portfolio = if portfolio_data + portfolio_data.is_a?(String) ? JSON.parse(portfolio_data) : portfolio_data + else + {} + end - # Update account with portfolio data - update_account_with_portfolio(account, parsed_portfolio, parsed_cash) + parsed_cash = if cash_data + cash_data.is_a?(String) ? JSON.parse(cash_data) : cash_data + else + nil + end - # Import holdings/positions - import_holdings(account, parsed_portfolio) - rescue JSON::ParserError => e - Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse portfolio data - #{e.message}" - end + # Get or create main account + account = find_or_create_main_account(parsed_portfolio) - def import_transactions + # Update account with portfolio data + update_account_with_portfolio(account, parsed_portfolio, parsed_cash) - begin - Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching transactions" - - # Find main account - account = traderepublic_item.traderepublic_accounts.first - return unless account - - # Get the date of the last synced transaction for incremental sync - since_date = account.last_transaction_date - # Force a full sync if no transaction actually exists - if account.linked_account.nil? || !account.linked_account.transactions.exists? - since_date = nil - Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Forcing initial full sync (no transactions exist)" - elsif since_date - Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Incremental sync from #{since_date}" - else - Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Initial full sync" + # Import holdings/positions + import_holdings(account, parsed_portfolio) + rescue JSON::ParserError => e + Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse portfolio data - #{e.message}" end - transactions_data = provider.get_timeline_transactions(since: since_date) - Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data class=#{transactions_data.class} keys=#{transactions_data.respond_to?(:keys) ? transactions_data.keys : 'n/a'}" + def import_transactions + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching transactions" + + account = traderepublic_item.traderepublic_accounts.first + return unless account + + since_date = account.last_transaction_date + if account.linked_account.nil? || !account.linked_account.transactions.exists? + since_date = nil + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Forcing initial full sync (no transactions exist)" + elsif since_date + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Incremental sync from #{since_date}" + else + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Initial full sync" + end + + transactions_data = provider.get_timeline_transactions(since: since_date) + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data class=#{transactions_data.class} keys=#{transactions_data.respond_to?(:keys) ? transactions_data.keys : "n/a"}" Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data preview=#{transactions_data.inspect[0..300]}" return unless transactions_data parsed = transactions_data.is_a?(String) ? JSON.parse(transactions_data) : transactions_data - Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: parsed class=#{parsed.class} keys=#{parsed.respond_to?(:keys) ? parsed.keys : 'n/a'}" + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: parsed class=#{parsed.class} keys=#{parsed.respond_to?(:keys) ? parsed.keys : "n/a"}" Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: parsed preview=#{parsed.inspect[0..300]}" - # Add instrument details for each transaction (if ISIN present) items = if parsed.is_a?(Hash) parsed["items"] elsif parsed.is_a?(Array) @@ -123,14 +116,13 @@ class TraderepublicItem::Importer pair ? pair[1] : nil end - if items.is_a?(Array) Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: items count before enrichment = #{items.size}" items.each do |txn| - # Enrich with instrument_details (ISIN) if possible isin = txn["isin"] isin ||= txn.dig("instrument", "isin") isin ||= extract_isin_from_icon(txn["icon"]) + if isin.present? && isin.match?(/^[A-Z]{2}[A-Z0-9]{10}$/) begin instrument_details = provider.get_instrument_details(isin) @@ -139,7 +131,7 @@ class TraderepublicItem::Importer Rails.logger.warn "TraderepublicItem #{traderepublic_item.id}: Failed to fetch instrument details for ISIN #{isin} - #{e.message}" end end - # Enrich with trade_details (timelineDetailV2) for each transaction + begin trade_details = provider.get_timeline_detail(txn["id"]) txn["trade_details"] = trade_details if trade_details.present? @@ -150,19 +142,13 @@ class TraderepublicItem::Importer Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: items count after enrichment = #{items.size}" end - - - # Detailed log before saving the snapshot items_count = items.is_a?(Array) ? items.size : 0 - preview = items.is_a?(Array) && items_count > 0 ? items.first(2).map { |i| i.slice('id', 'title', 'isin') } : items.inspect + preview = items.is_a?(Array) && items_count > 0 ? items.first(2).map { |i| i.slice("id", "title", "isin") } : items.inspect Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Transactions snapshot contains #{items_count} items (with instrument details). Preview: #{preview}" - - # Update account with transactions data account.upsert_traderepublic_transactions_snapshot!(parsed) Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Snapshot saved with #{items_count} items." - # Process transactions process_transactions(account, parsed) rescue JSON::ParserError => e Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse transactions - #{e.message}" @@ -172,125 +158,125 @@ class TraderepublicItem::Importer raise end - def find_or_create_main_account(portfolio_data) - # TradeRepublic typically has one main account - account = traderepublic_item.traderepublic_accounts.first_or_initialize( - account_id: "main", - name: "Trade Republic", - currency: "EUR" - ) - - account.save! if account.new_record? - account - end - - def update_account_with_portfolio(account, portfolio_data, cash_data = nil) - # Extract cash/balance from portfolio if available - cash_value = extract_cash_value(portfolio_data, cash_data) - - account.upsert_traderepublic_snapshot!({ - id: "main", - name: "Trade Republic", - currency: "EUR", - balance: cash_value, - status: "active", - type: "investment", - raw: portfolio_data - }) - end - - def extract_cash_value(portfolio_data, cash_data = nil) - # Try to extract cash value from cash_data first - if cash_data.is_a?(Array) && cash_data.first.is_a?(Hash) - # [{"accountNumber"=>"...", "currencyId"=>"EUR", "amount"=>1064.3}] - return cash_data.first["amount"] - end - - # Try to extract cash value from portfolio structure - # This depends on the actual API response structure - return 0 unless portfolio_data.is_a?(Hash) - - # Common patterns in trading APIs - portfolio_data.dig("cash", "value") || - portfolio_data.dig("availableCash") || - portfolio_data.dig("balance") || - 0 - end - - def import_holdings(account, portfolio_data) - positions = extract_positions(portfolio_data) - return if positions.empty? - - Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Processing #{positions.size} positions" - - linked_account = account.linked_account - return unless linked_account - - positions.each do |position| - security = find_or_create_security_from_tr(position) - holding_date = position["date"] || Date.current # fallback to today if nil - next unless holding_date.present? - holding = Holding.find_or_initialize_by( - account: linked_account, - security: security, - date: holding_date, - currency: position["currency"] + def find_or_create_main_account(portfolio_data) + # TradeRepublic typically has one main account + account = traderepublic_item.traderepublic_accounts.first_or_initialize( + account_id: "main", + name: "Trade Republic", + currency: "EUR" ) - holding.qty = position["quantity"] - holding.price = position["price"] - holding.save! + + account.save! if account.new_record? + account end - end - def extract_positions(portfolio_data) - return [] unless portfolio_data.is_a?(Hash) + def update_account_with_portfolio(account, portfolio_data, cash_data = nil) + # Extract cash/balance from portfolio if available + cash_value = extract_cash_value(portfolio_data, cash_data) - # Extract positions based on the Portfolio interface structure - categories = portfolio_data["categories"] || [] + account.upsert_traderepublic_snapshot!({ + id: "main", + name: "Trade Republic", + currency: "EUR", + balance: cash_value, + status: "active", + type: "investment", + raw: portfolio_data + }) + end - positions = [] - categories.each do |category| - next unless category["positions"].is_a?(Array) + def extract_cash_value(portfolio_data, cash_data = nil) + # Try to extract cash value from cash_data first + if cash_data.is_a?(Array) && cash_data.first.is_a?(Hash) + # [{"accountNumber"=>"...", "currencyId"=>"EUR", "amount"=>1064.3}] + return cash_data.first["amount"] + end - category["positions"].each do |position| - positions << position + # Try to extract cash value from portfolio structure + # This depends on the actual API response structure + return 0 unless portfolio_data.is_a?(Hash) + + # Common patterns in trading APIs + portfolio_data.dig("cash", "value") || + portfolio_data.dig("availableCash") || + portfolio_data.dig("balance") || + 0 + end + + def import_holdings(account, portfolio_data) + positions = extract_positions(portfolio_data) + return if positions.empty? + + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Processing #{positions.size} positions" + + linked_account = account.linked_account + return unless linked_account + + positions.each do |position| + security = find_or_create_security_from_tr(position) + holding_date = position["date"] || Date.current # fallback to today if nil + next unless holding_date.present? + holding = Holding.find_or_initialize_by( + account: linked_account, + security: security, + date: holding_date, + currency: position["currency"] + ) + holding.qty = position["quantity"] + holding.price = position["price"] + holding.save! end end - positions - end + def extract_positions(portfolio_data) + return [] unless portfolio_data.is_a?(Hash) - def extract_isin_from_icon(icon) - return nil unless icon.is_a?(String) - match = icon.match(%r{logos/([A-Z]{2}[A-Z0-9]{9}\d)/}) - match ? match[1] : nil - end + # Extract positions based on the Portfolio interface structure + categories = portfolio_data["categories"] || [] - def process_transactions(account, transactions_data) - return unless transactions_data.is_a?(Array) + positions = [] + categories.each do |category| + next unless category["positions"].is_a?(Array) - Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Processing #{transactions_data.size} transactions" - - linked_account = account.linked_account - return unless linked_account - - trades = [] - transactions_data.each do |txn| - security = find_or_create_security_from_tr(txn) - trade = Trade.create!( - account: linked_account, - security: security, - qty: txn["quantity"], - price: txn["price"], - date: txn["date"], - currency: txn["currency"] - ) - if block_given? - yield trade - else - trades << trade + category["positions"].each do |position| + positions << position + end end + + positions + end + + def extract_isin_from_icon(icon) + return nil unless icon.is_a?(String) + match = icon.match(%r{logos/([A-Z]{2}[A-Z0-9]{9}\d)/}) + match ? match[1] : nil + end + + def process_transactions(account, transactions_data) + return unless transactions_data.is_a?(Array) + + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Processing #{transactions_data.size} transactions" + + linked_account = account.linked_account + return unless linked_account + + trades = [] + transactions_data.each do |txn| + security = find_or_create_security_from_tr(txn) + trade = Trade.create!( + account: linked_account, + security: security, + qty: txn["quantity"], + price: txn["price"], + date: txn["date"], + currency: txn["currency"] + ) + if block_given? + yield trade + else + trades << trade + end + end + trades unless block_given? end - trades unless block_given? - end end diff --git a/app/models/traderepublic_item/syncer.rb b/app/models/traderepublic_item/syncer.rb index 86d251094..6e31bead6 100644 --- a/app/models/traderepublic_item/syncer.rb +++ b/app/models/traderepublic_item/syncer.rb @@ -16,12 +16,12 @@ class TraderepublicItem::Syncer # Phase 2: Import data from TradeRepublic API sync.update!(status_text: "Importing portfolio from Trade Republic...") if sync.respond_to?(:status_text) - + begin traderepublic_item.import_latest_traderepublic_data(sync: sync) rescue TraderepublicError => e Rails.logger.error "TraderepublicItem::Syncer - Import failed: #{e.message}" - + # Mark as requires_update if authentication error if [ :unauthorized, :auth_failed ].include?(e.error_code) traderepublic_item.update!(status: :requires_update) diff --git a/db/schema.rb b/db/schema.rb index 8b76571a6..b926c3ae4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1708,12 +1708,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do add_foreign_key "taggings", "tags" add_foreign_key "tags", "families" add_foreign_key "tool_calls", "messages" -<<<<<<< HEAD -======= add_foreign_key "traderepublic_accounts", "traderepublic_items" add_foreign_key "traderepublic_items", "families" - add_foreign_key "trades", "categories" ->>>>>>> Add TradeRepublic provider add_foreign_key "trades", "securities" add_foreign_key "transactions", "categories", on_delete: :nullify add_foreign_key "transactions", "merchants"