Fix TradeRepublic rebase CI failures

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

View File

@@ -199,6 +199,7 @@ GEM
erubi (1.13.1) 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -1708,12 +1708,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do
add_foreign_key "taggings", "tags" add_foreign_key "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"