Fix TradeRepublic rebase CI failures

This commit is contained in:
Juan José Mata
2026-04-18 22:38:02 +02:00
parent 727399a2be
commit e12c2e5db5
10 changed files with 927 additions and 938 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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)

4
db/schema.rb generated
View File

@@ -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"