mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Fix TradeRepublic rebase CI failures
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
db/schema.rb
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user