mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 23:25:00 +00:00
Fix TradeRepublic rebase CI failures
This commit is contained in:
@@ -199,6 +199,7 @@ GEM
|
|||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
|
event_emitter (0.2.6)
|
||||||
event_stream_parser (1.0.0)
|
event_stream_parser (1.0.0)
|
||||||
faker (3.5.2)
|
faker (3.5.2)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
@@ -760,6 +761,11 @@ GEM
|
|||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
websocket (1.2.11)
|
websocket (1.2.11)
|
||||||
|
websocket-client-simple (0.9.0)
|
||||||
|
base64
|
||||||
|
event_emitter
|
||||||
|
mutex_m
|
||||||
|
websocket
|
||||||
websocket-driver (0.8.0)
|
websocket-driver (0.8.0)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
@@ -875,6 +881,7 @@ DEPENDENCIES
|
|||||||
view_component
|
view_component
|
||||||
web-console
|
web-console
|
||||||
webmock
|
webmock
|
||||||
|
websocket-client-simple
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.4.7p58
|
ruby 3.4.7p58
|
||||||
|
|||||||
@@ -89,14 +89,14 @@ class TraderepublicItemsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def complete_login
|
def complete_login
|
||||||
@traderepublic_item = Current.family.traderepublic_items.find(params[:id])
|
@traderepublic_item = Current.family.traderepublic_items.find(params[:id])
|
||||||
device_pin = params[:device_pin]
|
device_pin = params[:device_pin]
|
||||||
manual_sync = params[:manual_sync].to_s == 'true' || params[:manual_sync] == '1'
|
manual_sync = params[:manual_sync].to_s == "true" || params[:manual_sync] == "1"
|
||||||
|
|
||||||
if device_pin.blank?
|
if device_pin.blank?
|
||||||
render json: { success: false, error: t(".pin_required", default: "PIN is required") }, status: :unprocessable_entity
|
render json: { success: false, error: t(".pin_required", default: "PIN is required") }, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
success = @traderepublic_item.complete_login!(device_pin)
|
success = @traderepublic_item.complete_login!(device_pin)
|
||||||
@@ -143,9 +143,9 @@ class TraderepublicItemsController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
render json: {
|
render json: {
|
||||||
success: false,
|
success: false,
|
||||||
error: t(".sync_failed", default: "Connection successful but failed to fetch accounts. Please try syncing manually.")
|
error: t(".sync_failed", default: "Connection successful but failed to fetch accounts. Please try syncing manually.")
|
||||||
}, status: :unprocessable_entity
|
}, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -252,12 +252,12 @@ class TraderepublicItemsController < ApplicationController
|
|||||||
accountable_type: accountable_type,
|
accountable_type: accountable_type,
|
||||||
accountable_attributes: {}
|
accountable_attributes: {}
|
||||||
)
|
)
|
||||||
|
|
||||||
Account.transaction do
|
Account.transaction do
|
||||||
account.save!
|
account.save!
|
||||||
# Skip opening balance creation entirely for TradeRepublic accounts
|
# Skip opening balance creation entirely for TradeRepublic accounts
|
||||||
end
|
end
|
||||||
|
|
||||||
account.sync_later
|
account.sync_later
|
||||||
|
|
||||||
# Link account via account_providers
|
# Link account via account_providers
|
||||||
@@ -272,21 +272,21 @@ class TraderepublicItemsController < ApplicationController
|
|||||||
if created_accounts.any?
|
if created_accounts.any?
|
||||||
# Reload to pick up the newly created account_provider associations
|
# Reload to pick up the newly created account_provider associations
|
||||||
traderepublic_item.reload
|
traderepublic_item.reload
|
||||||
|
|
||||||
# Process transactions immediately for the newly linked accounts
|
# Process transactions immediately for the newly linked accounts
|
||||||
# This creates Entry records from the raw transaction data
|
# This creates Entry records from the raw transaction data
|
||||||
traderepublic_item.process_accounts
|
traderepublic_item.process_accounts
|
||||||
|
|
||||||
# Trigger full sync in background to update balances and get latest data
|
# Trigger full sync in background to update balances and get latest data
|
||||||
traderepublic_item.sync_later
|
traderepublic_item.sync_later
|
||||||
|
|
||||||
# Redirect to the newly created account if single account, or accounts list if multiple
|
# Redirect to the newly created account if single account, or accounts list if multiple
|
||||||
# Avoid redirecting back to /accounts/new
|
# Avoid redirecting back to /accounts/new
|
||||||
redirect_path = if return_to == new_account_path || return_to.blank?
|
redirect_path = if return_to == new_account_path || return_to.blank?
|
||||||
created_accounts.size == 1 ? account_path(created_accounts.first) : accounts_path
|
created_accounts.size == 1 ? account_path(created_accounts.first) : accounts_path
|
||||||
else
|
else
|
||||||
return_to
|
return_to
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to redirect_path, notice: t(".accounts_linked",
|
redirect_to redirect_path, notice: t(".accounts_linked",
|
||||||
count: created_accounts.count,
|
count: created_accounts.count,
|
||||||
@@ -313,7 +313,7 @@ class TraderepublicItemsController < ApplicationController
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@traderepublic_item.destroy_later
|
@traderepublic_item.destroy_later
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.turbo_stream do
|
format.turbo_stream do
|
||||||
flash.now[:notice] = t(".scheduled_for_deletion", default: "Trade Republic connection scheduled for deletion")
|
flash.now[:notice] = t(".scheduled_for_deletion", default: "Trade Republic connection scheduled for deletion")
|
||||||
@@ -330,7 +330,7 @@ class TraderepublicItemsController < ApplicationController
|
|||||||
|
|
||||||
def sync
|
def sync
|
||||||
@traderepublic_item.sync_later
|
@traderepublic_item.sync_later
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.turbo_stream do
|
format.turbo_stream do
|
||||||
flash.now[:notice] = t(".sync_started", default: "Sync started")
|
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 "TradeRepublic reauthenticate action called"
|
||||||
Rails.logger.info "Request format: #{request.format}"
|
Rails.logger.info "Request format: #{request.format}"
|
||||||
Rails.logger.info "Turbo frame: #{request.headers['Turbo-Frame']}"
|
Rails.logger.info "Turbo frame: #{request.headers['Turbo-Frame']}"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
result = @traderepublic_item.initiate_login!
|
result = @traderepublic_item.initiate_login!
|
||||||
Rails.logger.info "Login initiated successfully"
|
Rails.logger.info "Login initiated successfully"
|
||||||
@@ -370,7 +370,7 @@ class TraderepublicItemsController < ApplicationController
|
|||||||
end
|
end
|
||||||
rescue TraderepublicError => e
|
rescue TraderepublicError => e
|
||||||
Rails.logger.error "TradeRepublic re-authentication initiation failed: #{e.message}"
|
Rails.logger.error "TradeRepublic re-authentication initiation failed: #{e.message}"
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.turbo_stream do
|
format.turbo_stream do
|
||||||
flash.now[:alert] = t(".login_failed", default: "Re-authentication failed: #{e.message}")
|
flash.now[:alert] = t(".login_failed", default: "Re-authentication failed: #{e.message}")
|
||||||
@@ -457,35 +457,35 @@ class TraderepublicItemsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_traderepublic_item
|
def set_traderepublic_item
|
||||||
@traderepublic_item = Current.family.traderepublic_items.find(params[:id])
|
@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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Only allow local paths: no scheme, no host, starts with a single leading slash (not protocol-relative //)
|
def traderepublic_item_params
|
||||||
path = uri.path || decoded
|
params.fetch(:traderepublic_item, {}).permit(:name, :phone_number, :pin)
|
||||||
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
|
end
|
||||||
|
|
||||||
new_account_path
|
def safe_return_to_path
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ class Holding::ForwardCalculator
|
|||||||
prev_qty = nil
|
prev_qty = nil
|
||||||
sorted.each do |h|
|
sorted.each do |h|
|
||||||
# Note: this condition (h.qty.to_f > 0 && h.amount.to_f > 0)
|
# Note: this condition (h.qty.to_f > 0 && h.amount.to_f > 0)
|
||||||
# intentionally filters out holdings where quantity > 0 but amount == 0
|
# intentionally filters out holdings where quantity > 0 but amount == 0
|
||||||
# (for example when price is missing or zero). If zero-amount records
|
# (for example when price is missing or zero). If zero-amount records
|
||||||
# should be treated as valid, consider falling back to a price lookup
|
# should be treated as valid, consider falling back to a price lookup
|
||||||
# or include qty>0 entries and compute amount from a known price.
|
# or include qty>0 entries and compute amount from a known price.
|
||||||
if h.qty.to_f > 0 && h.amount.to_f > 0
|
if h.qty.to_f > 0 && h.amount.to_f > 0
|
||||||
valid_holdings << h
|
valid_holdings << h
|
||||||
elsif h.qty.to_f == 0
|
elsif h.qty.to_f == 0
|
||||||
if prev_qty.nil?
|
if prev_qty.nil?
|
||||||
# Allow initial zero holding (initial portfolio state)
|
# Allow initial zero holding (initial portfolio state)
|
||||||
@@ -47,7 +47,7 @@ class Holding::ForwardCalculator
|
|||||||
prev_qty = h.qty.to_f
|
prev_qty = h.qty.to_f
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Holding.gapfill(valid_holdings)
|
Holding.gapfill(valid_holdings)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,67 +2,67 @@ require "websocket-client-simple"
|
|||||||
require "json"
|
require "json"
|
||||||
|
|
||||||
class Provider::Traderepublic
|
class Provider::Traderepublic
|
||||||
# Batch fetch instrument details for a list of ISINs
|
# Batch fetch instrument details for a list of ISINs
|
||||||
# Returns a hash { isin => instrument_details }
|
# Returns a hash { isin => instrument_details }
|
||||||
def batch_fetch_instrument_details(isins)
|
def batch_fetch_instrument_details(isins)
|
||||||
results = {}
|
results = {}
|
||||||
batch_websocket_calls do |batch|
|
batch_websocket_calls do |batch|
|
||||||
isins.uniq.each do |isin|
|
isins.uniq.each do |isin|
|
||||||
results[isin] = batch.get_instrument_details(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
|
|
||||||
end
|
end
|
||||||
# Execute several subscribe_once calls in a single WebSocket session
|
end
|
||||||
# Usage: batch_websocket_calls { |batch| batch.get_portfolio; batch.get_cash }
|
results
|
||||||
def batch_websocket_calls
|
end
|
||||||
connect_websocket
|
# Helper: Get portfolio, cash et available_cash en un seul batch WebSocket
|
||||||
batch_proxy = BatchWebSocketProxy.new(self)
|
def get_portfolio_and_cash_batch
|
||||||
yield batch_proxy
|
results = {}
|
||||||
# Optionally, small sleep to allow last messages to arrive
|
batch_websocket_calls do |batch|
|
||||||
sleep 0.5
|
results[:portfolio] = batch.get_portfolio
|
||||||
ensure
|
results[:cash] = batch.get_cash
|
||||||
disconnect_websocket
|
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
|
end
|
||||||
|
|
||||||
# Proxy to expose only subscribe_once helpers on an open connection
|
def get_portfolio
|
||||||
class BatchWebSocketProxy
|
@provider.subscribe_once("compactPortfolioByType")
|
||||||
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
|
|
||||||
end
|
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
|
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"
|
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,
|
phoneNumber: @phone_number,
|
||||||
pin: @pin
|
pin: @pin
|
||||||
}
|
}
|
||||||
|
|
||||||
Rails.logger.info "TradeRepublic: Initiating login for phone: #{@phone_number.to_s.gsub(/\d(?=\d{4})/, '*')}"
|
Rails.logger.info "TradeRepublic: Initiating login for phone: #{@phone_number.to_s.gsub(/\d(?=\d{4})/, '*')}"
|
||||||
sanitized_payload = payload.dup
|
sanitized_payload = payload.dup
|
||||||
if sanitized_payload[:phoneNumber]
|
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
|
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}"
|
Rails.logger.debug "TradeRepublic: Request payload: #{sanitized_payload.to_json}"
|
||||||
|
|
||||||
response = self.class.post(
|
response = self.class.post(
|
||||||
"#{HOST}/api/v1/auth/web/login",
|
"#{HOST}/api/v1/auth/web/login",
|
||||||
headers: default_headers,
|
headers: default_headers,
|
||||||
@@ -124,7 +124,7 @@ class Provider::Traderepublic
|
|||||||
# Extract and store JSESSIONID cookie for subsequent requests
|
# Extract and store JSESSIONID cookie for subsequent requests
|
||||||
if response.headers["set-cookie"]
|
if response.headers["set-cookie"]
|
||||||
set_cookies = 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|
|
set_cookies.each do |cookie|
|
||||||
if cookie.start_with?("JSESSIONID=")
|
if cookie.start_with?("JSESSIONID=")
|
||||||
@jsessionid = cookie.split(";").first
|
@jsessionid = cookie.split(";").first
|
||||||
@@ -147,17 +147,17 @@ class Provider::Traderepublic
|
|||||||
|
|
||||||
url = "#{HOST}/api/v1/auth/web/login/#{@process_id}/#{device_pin}"
|
url = "#{HOST}/api/v1/auth/web/login/#{@process_id}/#{device_pin}"
|
||||||
headers = default_headers
|
headers = default_headers
|
||||||
|
|
||||||
# Include JSESSIONID cookie if available
|
# Include JSESSIONID cookie if available
|
||||||
if @jsessionid
|
if @jsessionid
|
||||||
headers["Cookie"] = @jsessionid
|
headers["Cookie"] = @jsessionid
|
||||||
Rails.logger.info "TradeRepublic: Including JSESSIONID in verification request"
|
Rails.logger.info "TradeRepublic: Including JSESSIONID in verification request"
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.info "TradeRepublic: Verifying device PIN for processId: #{@process_id}"
|
Rails.logger.info "TradeRepublic: Verifying device PIN for processId: #{@process_id}"
|
||||||
Rails.logger.debug "TradeRepublic: Verification URL: #{url}"
|
Rails.logger.debug "TradeRepublic: Verification URL: #{url}"
|
||||||
Rails.logger.debug "TradeRepublic: Verification headers: #{headers.inspect}"
|
Rails.logger.debug "TradeRepublic: Verification headers: #{headers.inspect}"
|
||||||
|
|
||||||
# IMPORTANT: Use POST, not GET!
|
# IMPORTANT: Use POST, not GET!
|
||||||
response = self.class.post(
|
response = self.class.post(
|
||||||
url,
|
url,
|
||||||
@@ -221,7 +221,7 @@ class Provider::Traderepublic
|
|||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.info "TradeRepublic: Refreshing session token"
|
Rails.logger.info "TradeRepublic: Refreshing session token"
|
||||||
|
|
||||||
# Try the refresh endpoint first
|
# Try the refresh endpoint first
|
||||||
response = self.class.post(
|
response = self.class.post(
|
||||||
"#{HOST}/api/v1/auth/refresh",
|
"#{HOST}/api/v1/auth/refresh",
|
||||||
@@ -277,14 +277,14 @@ class Provider::Traderepublic
|
|||||||
|
|
||||||
ws.on :message do |msg|
|
ws.on :message do |msg|
|
||||||
Rails.logger.debug "TradeRepublic: WebSocket received message: #{msg.data.to_s.inspect[0..200]}"
|
Rails.logger.debug "TradeRepublic: WebSocket received message: #{msg.data.to_s.inspect[0..200]}"
|
||||||
|
|
||||||
# Mark as connected when we receive the "connected" response
|
# Mark as connected when we receive the "connected" response
|
||||||
if msg.data.start_with?("connected")
|
if msg.data.start_with?("connected")
|
||||||
Rails.logger.info "TradeRepublic: WebSocket confirmed connected"
|
Rails.logger.info "TradeRepublic: WebSocket confirmed connected"
|
||||||
provider.instance_variable_set(:@connected, true)
|
provider.instance_variable_set(:@connected, true)
|
||||||
provider.send(:start_echo_thread)
|
provider.send(:start_echo_thread)
|
||||||
end
|
end
|
||||||
|
|
||||||
provider.send(:handle_websocket_message, msg.data)
|
provider.send(:handle_websocket_message, msg.data)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ class Provider::Traderepublic
|
|||||||
timeout = Time.now + SESSION_VALIDATION_TIMEOUT
|
timeout = Time.now + SESSION_VALIDATION_TIMEOUT
|
||||||
while result.nil? && Time.now < timeout
|
while result.nil? && Time.now < timeout
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
|
|
||||||
# Check if an error was stored in the subscription
|
# Check if an error was stored in the subscription
|
||||||
subscription = nil
|
subscription = nil
|
||||||
@mutex.synchronize do
|
@mutex.synchronize do
|
||||||
@@ -387,7 +387,7 @@ class Provider::Traderepublic
|
|||||||
|
|
||||||
if result
|
if result
|
||||||
parsed = JSON.parse(result)
|
parsed = JSON.parse(result)
|
||||||
|
|
||||||
# Handle double-encoded JSON (some TR responses are JSON strings containing JSON)
|
# Handle double-encoded JSON (some TR responses are JSON strings containing JSON)
|
||||||
if parsed.is_a?(String) && (parsed.start_with?("{") || parsed.start_with?("["))
|
if parsed.is_a?(String) && (parsed.start_with?("{") || parsed.start_with?("["))
|
||||||
begin
|
begin
|
||||||
@@ -537,176 +537,176 @@ class Provider::Traderepublic
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def default_headers
|
def default_headers
|
||||||
{
|
{
|
||||||
"Content-Type" => "application/json",
|
"Content-Type" => "application/json",
|
||||||
"Accept" => "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",
|
"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",
|
"Origin" => "https://app.traderepublic.com",
|
||||||
"Referer" => "https://app.traderepublic.com/",
|
"Referer" => "https://app.traderepublic.com/",
|
||||||
"Accept-Language" => "en",
|
"Accept-Language" => "en",
|
||||||
"x-tr-platform" => "web",
|
"x-tr-platform" => "web",
|
||||||
"x-tr-app-version" => "12.12.0"
|
"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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
raise TraderepublicError.new("WebSocket connection timeout", :connection_timeout) unless @connected
|
def cookie_header
|
||||||
end
|
return {} if @raw_cookies.nil? || @raw_cookies.empty?
|
||||||
|
|
||||||
def start_echo_thread
|
# Join all cookies into a single Cookie header
|
||||||
@echo_thread = Thread.new do
|
cookie_string = @raw_cookies.map do |cookie|
|
||||||
loop do
|
# Extract just the name=value part before the first semicolon
|
||||||
sleep ECHO_INTERVAL
|
cookie.split(";").first
|
||||||
break unless @connected
|
end.join("; ")
|
||||||
send_echo
|
|
||||||
|
{ "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
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def send_echo
|
def extract_cookie_value(name)
|
||||||
@ws&.send("echo #{Time.now.to_i * 1000}")
|
@raw_cookies.each do |cookie|
|
||||||
rescue => e
|
match = cookie.match(/#{name}=([^;]+)/)
|
||||||
Rails.logger.warn "TradeRepublic: Failed to send echo - #{e.message}"
|
return match[1] if match
|
||||||
end
|
end
|
||||||
|
nil
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check for authentication errors
|
def wait_for_connection
|
||||||
if data.is_a?(Hash) && data["errors"]
|
timeout = Time.now + WS_CONNECTION_TIMEOUT
|
||||||
auth_error = data["errors"].find { |err| err["errorCode"] == "AUTHENTICATION_ERROR" }
|
until @connected || Time.now > timeout
|
||||||
if auth_error
|
sleep 0.1
|
||||||
Rails.logger.error "TradeRepublic: Authentication error received - #{auth_error['errorMessage']}"
|
end
|
||||||
# Store error for the subscription callback
|
|
||||||
if sub_id && @subscriptions[sub_id]
|
raise TraderepublicError.new("WebSocket connection timeout", :connection_timeout) unless @connected
|
||||||
@subscriptions[sub_id][:error] = TraderepublicError.new(auth_error["errorMessage"] || "Unauthorized", :auth_failed)
|
end
|
||||||
|
|
||||||
|
def start_echo_thread
|
||||||
|
@echo_thread = Thread.new do
|
||||||
|
loop do
|
||||||
|
sleep ECHO_INTERVAL
|
||||||
|
break unless @connected
|
||||||
|
send_echo
|
||||||
end
|
end
|
||||||
end
|
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
|
begin
|
||||||
# If there's an error stored, raise it
|
data = JSON.parse(json_string)
|
||||||
raise subscription[:error] if subscription[:error]
|
rescue JSON::ParserError
|
||||||
|
Rails.logger.error "TradeRepublic: Failed to parse WebSocket message JSON"
|
||||||
subscription[:callback].call(json_string)
|
return
|
||||||
rescue => e
|
end
|
||||||
Rails.logger.error "TradeRepublic: Subscription callback error - #{e.message}"
|
|
||||||
raise if e.is_a?(TraderepublicError) # Re-raise TraderepublicError to propagate auth failures
|
# 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
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def parse_websocket_payload(raw_message)
|
def parse_websocket_payload(raw_message)
|
||||||
# Find the first occurrence of { or [
|
# Find the first occurrence of { or [
|
||||||
start_index_obj = raw_message.index("{")
|
start_index_obj = raw_message.index("{")
|
||||||
start_index_arr = 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
|
|
||||||
|
|
||||||
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
|
return nil unless start_index
|
||||||
id_match = id_part.match(/\d+/)
|
|
||||||
subscription_id = id_match ? id_match[0].to_i : nil
|
|
||||||
|
|
||||||
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 }
|
json_data = raw_message[start_index..-1].strip
|
||||||
end
|
|
||||||
|
|
||||||
def build_message(type, params = {})
|
{ subscription_id: subscription_id, json_data: json_data }
|
||||||
{ type: type, token: @session_token }.merge(params)
|
end
|
||||||
end
|
|
||||||
|
def build_message(type, params = {})
|
||||||
def send_subscription(sub_id, message)
|
{ type: type, token: @session_token }.merge(params)
|
||||||
payload = "sub #{sub_id} #{message.to_json}"
|
end
|
||||||
@ws.send(payload)
|
|
||||||
end
|
def send_subscription(sub_id, message)
|
||||||
|
payload = "sub #{sub_id} #{message.to_json}"
|
||||||
def handle_http_response(response)
|
@ws.send(payload)
|
||||||
Rails.logger.error "TradeRepublic: HTTP response code=#{response.code}, body=#{response.body}"
|
end
|
||||||
|
|
||||||
case response.code
|
def handle_http_response(response)
|
||||||
when 200
|
Rails.logger.error "TradeRepublic: HTTP response code=#{response.code}, body=#{response.body}"
|
||||||
JSON.parse(response.body)
|
|
||||||
when 400
|
case response.code
|
||||||
raise TraderepublicError.new("Bad request: #{response.body}", :bad_request)
|
when 200
|
||||||
when 401
|
JSON.parse(response.body)
|
||||||
raise TraderepublicError.new("Invalid credentials", :unauthorized)
|
when 400
|
||||||
when 403
|
raise TraderepublicError.new("Bad request: #{response.body}", :bad_request)
|
||||||
raise TraderepublicError.new("Access forbidden", :forbidden)
|
when 401
|
||||||
when 404
|
raise TraderepublicError.new("Invalid credentials", :unauthorized)
|
||||||
raise TraderepublicError.new("Resource not found", :not_found)
|
when 403
|
||||||
when 429
|
raise TraderepublicError.new("Access forbidden", :forbidden)
|
||||||
raise TraderepublicError.new("Rate limit exceeded", :rate_limit_exceeded)
|
when 404
|
||||||
when 500..599
|
raise TraderepublicError.new("Resource not found", :not_found)
|
||||||
raise TraderepublicError.new("Server error: #{response.code}", :server_error)
|
when 429
|
||||||
else
|
raise TraderepublicError.new("Rate limit exceeded", :rate_limit_exceeded)
|
||||||
raise TraderepublicError.new("Unexpected response: #{response.code}", :unexpected_response)
|
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
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class Provider::TraderepublicAdapter < Provider::Base
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def provider_account
|
def provider_account
|
||||||
@provider_account ||= TraderepublicAccount.find(@account_provider.provider_id)
|
@provider_account ||= TraderepublicAccount.find(@account_provider.provider_id)
|
||||||
end
|
end
|
||||||
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
|
TraderepublicItem::Importer.new(self, traderepublic_provider: provider).import
|
||||||
rescue TraderepublicError => e
|
rescue TraderepublicError => e
|
||||||
# If authentication failed and we have credentials, try re-authenticating automatically
|
# 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"
|
Rails.logger.warn "TraderepublicItem #{id} - Authentication failed, attempting automatic re-authentication"
|
||||||
|
|
||||||
if auto_reauthenticate
|
if auto_reauthenticate
|
||||||
Rails.logger.info "TraderepublicItem #{id} - Re-authentication successful, retrying import"
|
Rails.logger.info "TraderepublicItem #{id} - Re-authentication successful, retrying import"
|
||||||
# Retry import with fresh tokens (skip_token_refresh to avoid infinite loop)
|
# 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
|
else
|
||||||
Rails.logger.error "TraderepublicItem #{id} - Automatic re-authentication failed"
|
Rails.logger.error "TraderepublicItem #{id} - Automatic re-authentication failed"
|
||||||
update!(status: :requires_update)
|
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
|
# Trade Republic doesn't support token refresh, so we need to re-authenticate from scratch
|
||||||
def auto_reauthenticate
|
def auto_reauthenticate
|
||||||
Rails.logger.info "TraderepublicItem #{id}: Starting automatic re-authentication"
|
Rails.logger.info "TraderepublicItem #{id}: Starting automatic re-authentication"
|
||||||
|
|
||||||
unless credentials_configured?
|
unless credentials_configured?
|
||||||
Rails.logger.error "TraderepublicItem #{id}: Cannot auto re-authenticate - credentials not configured"
|
Rails.logger.error "TraderepublicItem #{id}: Cannot auto re-authenticate - credentials not configured"
|
||||||
return false
|
return false
|
||||||
@@ -155,14 +155,14 @@ class TraderepublicItem < ApplicationRecord
|
|||||||
begin
|
begin
|
||||||
# Step 1: Initiate login to get processId
|
# Step 1: Initiate login to get processId
|
||||||
result = initiate_login!
|
result = initiate_login!
|
||||||
|
|
||||||
Rails.logger.info "TraderepublicItem #{id}: Login initiated, processId: #{process_id}"
|
Rails.logger.info "TraderepublicItem #{id}: Login initiated, processId: #{process_id}"
|
||||||
|
|
||||||
# Trade Republic requires SMS verification - we can't auto-complete this step
|
# Trade Republic requires SMS verification - we can't auto-complete this step
|
||||||
# Mark as requires_update so user knows they need to re-authenticate
|
# 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"
|
Rails.logger.warn "TraderepublicItem #{id}: SMS verification required - automatic re-authentication cannot proceed"
|
||||||
update!(status: :requires_update)
|
update!(status: :requires_update)
|
||||||
|
|
||||||
false
|
false
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "TraderepublicItem #{id}: Automatic re-authentication failed - #{e.message}"
|
Rails.logger.error "TraderepublicItem #{id}: Automatic re-authentication failed - #{e.message}"
|
||||||
@@ -220,18 +220,18 @@ class TraderepublicItem < ApplicationRecord
|
|||||||
success = importer.import
|
success = importer.import
|
||||||
if success
|
if success
|
||||||
sync.complete!
|
sync.complete!
|
||||||
return true
|
true
|
||||||
else
|
else
|
||||||
sync.fail!
|
sync.fail!
|
||||||
sync.update(error: "Import failed")
|
sync.update(error: "Import failed")
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
rescue => e
|
rescue => e
|
||||||
sync.fail!
|
sync.fail!
|
||||||
sync.update(error: e.message)
|
sync.update(error: e.message)
|
||||||
Rails.logger.error "TraderepublicItem #{id} - perform_sync failed: #{e.class}: #{e.message}"
|
Rails.logger.error "TraderepublicItem #{id} - perform_sync failed: #{e.class}: #{e.message}"
|
||||||
Rails.logger.error e.backtrace.join("\n")
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ class TraderepublicItem::Importer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def import
|
def import
|
||||||
|
|
||||||
raise "Provider not configured" unless provider
|
raise "Provider not configured" unless provider
|
||||||
ensure_session_configured!
|
ensure_session_configured!
|
||||||
|
|
||||||
@@ -55,67 +54,61 @@ class TraderepublicItem::Importer
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def import_portfolio
|
def import_portfolio
|
||||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching portfolio data"
|
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching portfolio data"
|
||||||
|
|
||||||
portfolio_data = provider.get_portfolio
|
portfolio_data = provider.get_portfolio
|
||||||
cash_data = provider.get_cash
|
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
|
|
||||||
|
|
||||||
# Get or create main account
|
parsed_portfolio = if portfolio_data
|
||||||
account = find_or_create_main_account(parsed_portfolio)
|
portfolio_data.is_a?(String) ? JSON.parse(portfolio_data) : portfolio_data
|
||||||
|
else
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
# Update account with portfolio data
|
parsed_cash = if cash_data
|
||||||
update_account_with_portfolio(account, parsed_portfolio, parsed_cash)
|
cash_data.is_a?(String) ? JSON.parse(cash_data) : cash_data
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
# Import holdings/positions
|
# Get or create main account
|
||||||
import_holdings(account, parsed_portfolio)
|
account = find_or_create_main_account(parsed_portfolio)
|
||||||
rescue JSON::ParserError => e
|
|
||||||
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse portfolio data - #{e.message}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def import_transactions
|
# Update account with portfolio data
|
||||||
|
update_account_with_portfolio(account, parsed_portfolio, parsed_cash)
|
||||||
|
|
||||||
begin
|
# Import holdings/positions
|
||||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching transactions"
|
import_holdings(account, parsed_portfolio)
|
||||||
|
rescue JSON::ParserError => e
|
||||||
# Find main account
|
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse portfolio data - #{e.message}"
|
||||||
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"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
transactions_data = provider.get_timeline_transactions(since: since_date)
|
def import_transactions
|
||||||
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}: 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]}"
|
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data preview=#{transactions_data.inspect[0..300]}"
|
||||||
return unless transactions_data
|
return unless transactions_data
|
||||||
|
|
||||||
parsed = transactions_data.is_a?(String) ? JSON.parse(transactions_data) : 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]}"
|
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)
|
items = if parsed.is_a?(Hash)
|
||||||
parsed["items"]
|
parsed["items"]
|
||||||
elsif parsed.is_a?(Array)
|
elsif parsed.is_a?(Array)
|
||||||
@@ -123,14 +116,13 @@ class TraderepublicItem::Importer
|
|||||||
pair ? pair[1] : nil
|
pair ? pair[1] : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
if items.is_a?(Array)
|
if items.is_a?(Array)
|
||||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: items count before enrichment = #{items.size}"
|
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: items count before enrichment = #{items.size}"
|
||||||
items.each do |txn|
|
items.each do |txn|
|
||||||
# Enrich with instrument_details (ISIN) if possible
|
|
||||||
isin = txn["isin"]
|
isin = txn["isin"]
|
||||||
isin ||= txn.dig("instrument", "isin")
|
isin ||= txn.dig("instrument", "isin")
|
||||||
isin ||= extract_isin_from_icon(txn["icon"])
|
isin ||= extract_isin_from_icon(txn["icon"])
|
||||||
|
|
||||||
if isin.present? && isin.match?(/^[A-Z]{2}[A-Z0-9]{10}$/)
|
if isin.present? && isin.match?(/^[A-Z]{2}[A-Z0-9]{10}$/)
|
||||||
begin
|
begin
|
||||||
instrument_details = provider.get_instrument_details(isin)
|
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}"
|
Rails.logger.warn "TraderepublicItem #{traderepublic_item.id}: Failed to fetch instrument details for ISIN #{isin} - #{e.message}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
# Enrich with trade_details (timelineDetailV2) for each transaction
|
|
||||||
begin
|
begin
|
||||||
trade_details = provider.get_timeline_detail(txn["id"])
|
trade_details = provider.get_timeline_detail(txn["id"])
|
||||||
txn["trade_details"] = trade_details if trade_details.present?
|
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}"
|
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: items count after enrichment = #{items.size}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Detailed log before saving the snapshot
|
|
||||||
items_count = items.is_a?(Array) ? items.size : 0
|
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}"
|
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)
|
account.upsert_traderepublic_transactions_snapshot!(parsed)
|
||||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Snapshot saved with #{items_count} items."
|
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Snapshot saved with #{items_count} items."
|
||||||
|
|
||||||
# Process transactions
|
|
||||||
process_transactions(account, parsed)
|
process_transactions(account, parsed)
|
||||||
rescue JSON::ParserError => e
|
rescue JSON::ParserError => e
|
||||||
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse transactions - #{e.message}"
|
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse transactions - #{e.message}"
|
||||||
@@ -172,125 +158,125 @@ class TraderepublicItem::Importer
|
|||||||
raise
|
raise
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_main_account(portfolio_data)
|
def find_or_create_main_account(portfolio_data)
|
||||||
# TradeRepublic typically has one main account
|
# TradeRepublic typically has one main account
|
||||||
account = traderepublic_item.traderepublic_accounts.first_or_initialize(
|
account = traderepublic_item.traderepublic_accounts.first_or_initialize(
|
||||||
account_id: "main",
|
account_id: "main",
|
||||||
name: "Trade Republic",
|
name: "Trade Republic",
|
||||||
currency: "EUR"
|
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"]
|
|
||||||
)
|
)
|
||||||
holding.qty = position["quantity"]
|
|
||||||
holding.price = position["price"]
|
account.save! if account.new_record?
|
||||||
holding.save!
|
account
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def extract_positions(portfolio_data)
|
def update_account_with_portfolio(account, portfolio_data, cash_data = nil)
|
||||||
return [] unless portfolio_data.is_a?(Hash)
|
# Extract cash/balance from portfolio if available
|
||||||
|
cash_value = extract_cash_value(portfolio_data, cash_data)
|
||||||
|
|
||||||
# Extract positions based on the Portfolio interface structure
|
account.upsert_traderepublic_snapshot!({
|
||||||
categories = portfolio_data["categories"] || []
|
id: "main",
|
||||||
|
name: "Trade Republic",
|
||||||
|
currency: "EUR",
|
||||||
|
balance: cash_value,
|
||||||
|
status: "active",
|
||||||
|
type: "investment",
|
||||||
|
raw: portfolio_data
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
positions = []
|
def extract_cash_value(portfolio_data, cash_data = nil)
|
||||||
categories.each do |category|
|
# Try to extract cash value from cash_data first
|
||||||
next unless category["positions"].is_a?(Array)
|
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|
|
# Try to extract cash value from portfolio structure
|
||||||
positions << position
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
positions
|
def extract_positions(portfolio_data)
|
||||||
end
|
return [] unless portfolio_data.is_a?(Hash)
|
||||||
|
|
||||||
def extract_isin_from_icon(icon)
|
# Extract positions based on the Portfolio interface structure
|
||||||
return nil unless icon.is_a?(String)
|
categories = portfolio_data["categories"] || []
|
||||||
match = icon.match(%r{logos/([A-Z]{2}[A-Z0-9]{9}\d)/})
|
|
||||||
match ? match[1] : nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_transactions(account, transactions_data)
|
positions = []
|
||||||
return unless transactions_data.is_a?(Array)
|
categories.each do |category|
|
||||||
|
next unless category["positions"].is_a?(Array)
|
||||||
|
|
||||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Processing #{transactions_data.size} transactions"
|
category["positions"].each do |position|
|
||||||
|
positions << position
|
||||||
linked_account = account.linked_account
|
end
|
||||||
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
|
||||||
|
|
||||||
|
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
|
end
|
||||||
trades unless block_given?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ class TraderepublicItem::Syncer
|
|||||||
|
|
||||||
# Phase 2: Import data from TradeRepublic API
|
# Phase 2: Import data from TradeRepublic API
|
||||||
sync.update!(status_text: "Importing portfolio from Trade Republic...") if sync.respond_to?(:status_text)
|
sync.update!(status_text: "Importing portfolio from Trade Republic...") if sync.respond_to?(:status_text)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
traderepublic_item.import_latest_traderepublic_data(sync: sync)
|
traderepublic_item.import_latest_traderepublic_data(sync: sync)
|
||||||
rescue TraderepublicError => e
|
rescue TraderepublicError => e
|
||||||
Rails.logger.error "TraderepublicItem::Syncer - Import failed: #{e.message}"
|
Rails.logger.error "TraderepublicItem::Syncer - Import failed: #{e.message}"
|
||||||
|
|
||||||
# Mark as requires_update if authentication error
|
# Mark as requires_update if authentication error
|
||||||
if [ :unauthorized, :auth_failed ].include?(e.error_code)
|
if [ :unauthorized, :auth_failed ].include?(e.error_code)
|
||||||
traderepublic_item.update!(status: :requires_update)
|
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 "taggings", "tags"
|
||||||
add_foreign_key "tags", "families"
|
add_foreign_key "tags", "families"
|
||||||
add_foreign_key "tool_calls", "messages"
|
add_foreign_key "tool_calls", "messages"
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
add_foreign_key "traderepublic_accounts", "traderepublic_items"
|
add_foreign_key "traderepublic_accounts", "traderepublic_items"
|
||||||
add_foreign_key "traderepublic_items", "families"
|
add_foreign_key "traderepublic_items", "families"
|
||||||
add_foreign_key "trades", "categories"
|
|
||||||
>>>>>>> Add TradeRepublic provider
|
|
||||||
add_foreign_key "trades", "securities"
|
add_foreign_key "trades", "securities"
|
||||||
add_foreign_key "transactions", "categories", on_delete: :nullify
|
add_foreign_key "transactions", "categories", on_delete: :nullify
|
||||||
add_foreign_key "transactions", "merchants"
|
add_foreign_key "transactions", "merchants"
|
||||||
|
|||||||
Reference in New Issue
Block a user