Refactor TraderepublicItem sync methods and improve error handling in processor

This commit is contained in:
Juan José Mata
2026-04-18 21:46:59 +02:00
parent 0a96bf199d
commit 727399a2be
40 changed files with 3809 additions and 6 deletions

View File

@@ -79,6 +79,7 @@ gem "stripe"
gem "plaid"
gem "snaptrade", "~> 2.0"
gem "httparty"
gem "websocket-client-simple"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 3.0"
gem "activerecord-import"

View File

@@ -0,0 +1,491 @@
class TraderepublicItemsController < ApplicationController
before_action :set_traderepublic_item, only: [ :edit, :update, :destroy, :sync, :verify_pin, :complete_login, :reauthenticate, :manual_sync ]
def new
@traderepublic_item = TraderepublicItem.new(family: Current.family)
@accountable_type = params[:accountable_type]
@return_to = params[:return_to]
end
def index
@traderepublic_items = Current.family.traderepublic_items.includes(traderepublic_accounts: :account)
end
def create
@traderepublic_item = TraderepublicItem.new(traderepublic_item_params.merge(family: Current.family))
@accountable_type = params[:accountable_type]
@return_to = params[:return_to]
if @traderepublic_item.save
begin
@traderepublic_item.initiate_login!
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.update(
"modal",
partial: "traderepublic_items/verify_pin",
locals: { traderepublic_item: @traderepublic_item }
)
end
format.html do
redirect_to verify_pin_traderepublic_item_path(@traderepublic_item),
notice: t(".device_pin_sent", default: "Please check your phone for the verification PIN")
end
end
rescue TraderepublicError => e
@traderepublic_item.destroy if @traderepublic_item.persisted?
respond_to do |format|
format.turbo_stream do
flash.now[:alert] = t(".login_failed", default: "Login failed: #{e.message}")
render turbo_stream: turbo_stream.replace(
"traderepublic-providers-panel",
partial: "settings/providers/traderepublic_panel"
)
end
format.html do
redirect_to new_traderepublic_item_path, alert: t(".login_failed", default: "Login failed: #{e.message}")
end
end
end
else
respond_to do |format|
format.turbo_stream { render :new, status: :unprocessable_entity, layout: false }
format.html { render :new, status: :unprocessable_entity }
end
end
end
# Manual sync: déclenche le flow PIN (initiate_login) puis popup PIN
def manual_sync
begin
result = @traderepublic_item.initiate_login!
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.update(
"modal",
partial: "traderepublic_items/verify_pin",
locals: { traderepublic_item: @traderepublic_item, manual_sync: true }
)
end
format.html do
redirect_to verify_pin_traderepublic_item_path(@traderepublic_item, manual_sync: true),
notice: t(".device_pin_sent", default: "Please check your phone for the verification PIN")
end
end
rescue TraderepublicError => e
respond_to do |format|
format.turbo_stream do
flash.now[:alert] = t(".login_failed", default: "Manual sync failed: #{e.message}")
render turbo_stream: turbo_stream.replace(
"traderepublic-providers-panel",
partial: "settings/providers/traderepublic_panel"
)
end
format.html do
redirect_to traderepublic_items_path, alert: t(".login_failed", default: "Manual sync failed: #{e.message}")
end
end
end
end
def complete_login
@traderepublic_item = Current.family.traderepublic_items.find(params[:id])
device_pin = params[:device_pin]
manual_sync = params[:manual_sync].to_s == 'true' || params[:manual_sync] == '1'
if device_pin.blank?
render json: { success: false, error: t(".pin_required", default: "PIN is required") }, status: :unprocessable_entity
return
end
begin
success = @traderepublic_item.complete_login!(device_pin)
if success
if manual_sync
# Manual sync: fetch only new tranwsactions since last transaction for each account
@traderepublic_item.traderepublic_accounts.each do |tr_account|
last_date = tr_account.last_transaction_date
provider = @traderepublic_item.traderepublic_provider
# fetch new transactions (depuis la dernière date + 1 jour pour éviter doublons)
since = last_date ? last_date + 1.day : nil
new_snapshot = provider.get_timeline_transactions(since: since)
tr_account.upsert_traderepublic_transactions_snapshot!(new_snapshot)
end
@traderepublic_item.process_accounts
render json: {
success: true,
redirect_url: settings_providers_path
}
else
# Trigger initial sync synchronously to get accounts
# Skip token refresh since we just obtained fresh tokens
Rails.logger.info "TradeRepublic: Starting initial sync for item #{@traderepublic_item.id}"
sync_success = @traderepublic_item.import_latest_traderepublic_data(skip_token_refresh: true)
if sync_success
# Check if this is a re-authentication (has linked accounts) or new connection
has_linked_accounts = @traderepublic_item.traderepublic_accounts.joins(:account_provider).exists?
if has_linked_accounts
# Re-authentication: process existing accounts and redirect to settings
Rails.logger.info "TradeRepublic: Re-authentication detected, processing existing accounts"
@traderepublic_item.process_accounts
render json: {
success: true,
redirect_url: settings_providers_path
}
else
# New connection: redirect to account selection
render json: {
success: true,
redirect_url: select_accounts_traderepublic_items_path(
accountable_type: params[:accountable_type] || "Investment",
return_to: safe_return_to_path
)
}
end
else
render json: {
success: false,
error: t(".sync_failed", default: "Connection successful but failed to fetch accounts. Please try syncing manually.")
}, status: :unprocessable_entity
end
end
else
render json: { success: false, error: t(".verification_failed", default: "PIN verification failed") }, status: :unprocessable_entity
end
rescue TraderepublicError => e
Rails.logger.error "TradeRepublic PIN verification failed: \\#{e.message}"
render json: { success: false, error: e.message }, status: :unprocessable_entity
rescue => e
Rails.logger.error "Unexpected error during PIN verification: \\#{e.class}: \\#{e.message}"
render json: { success: false, error: t(".unexpected_error", default: "An unexpected error occurred") }, status: :internal_server_error
end
end
# Show accounts selection after successful login
def select_accounts
@accountable_type = params[:accountable_type] || "Investment"
@return_to = safe_return_to_path
# Find the most recent traderepublic_item with valid session
@traderepublic_item = Current.family.traderepublic_items
.where.not(session_token: nil)
.where(status: :good)
.order(updated_at: :desc)
.first
unless @traderepublic_item
redirect_to new_traderepublic_item_path, alert: t(".no_active_connection", default: "No active Trade Republic connection found")
return
end
# Get available accounts
@available_accounts = @traderepublic_item.traderepublic_accounts
# Filter out already linked accounts
linked_account_ids = @available_accounts.joins(:account_provider).pluck(:id)
@available_accounts = @available_accounts.where.not(id: linked_account_ids)
if @available_accounts.empty?
if turbo_frame_request?
@error_message = t(".no_accounts_available", default: "No Trade Republic accounts available for linking")
@return_path = @return_to || new_account_path
render partial: "traderepublic_items/api_error", locals: { error_message: @error_message, return_path: @return_path }, layout: false
else
redirect_to new_account_path, alert: t(".no_accounts_available", default: "No Trade Republic accounts available for linking")
end
return
end
render layout: turbo_frame_request? ? false : "application"
rescue => e
Rails.logger.error "Error in select_accounts: #{e.class}: #{e.message}"
@error_message = t(".error_loading_accounts", default: "Failed to load accounts")
@return_path = safe_return_to_path
render partial: "traderepublic_items/api_error",
locals: { error_message: @error_message, return_path: @return_path },
layout: false
end
# Link selected accounts
def link_accounts
selected_account_ids = params[:account_ids] || []
accountable_type = params[:accountable_type] || "Investment"
return_to = safe_return_to_path
if selected_account_ids.empty?
redirect_to new_account_path, alert: t(".no_accounts_selected", default: "No accounts selected")
return
end
traderepublic_item = Current.family.traderepublic_items
.where.not(session_token: nil)
.order(updated_at: :desc)
.first
unless traderepublic_item
redirect_to new_account_path, alert: t(".no_connection", default: "No Trade Republic connection found")
return
end
created_accounts = []
already_linked_accounts = []
selected_account_ids.each do |account_id|
traderepublic_account = traderepublic_item.traderepublic_accounts.find_by(id: account_id)
next unless traderepublic_account
# Check if already linked
if traderepublic_account.account_provider.present?
already_linked_accounts << traderepublic_account.name
next
end
# Create the internal Account
# For TradeRepublic (investment accounts), we don't create an opening balance
# because we have complete transaction history and holdings
account = Account.new(
family: Current.family,
name: traderepublic_account.name,
balance: 0, # Will be calculated from holdings and transactions
cash_balance: 0,
currency: traderepublic_account.currency || "EUR",
accountable_type: accountable_type,
accountable_attributes: {}
)
Account.transaction do
account.save!
# Skip opening balance creation entirely for TradeRepublic accounts
end
account.sync_later
# Link account via account_providers
AccountProvider.create!(
account: account,
provider: traderepublic_account
)
created_accounts << account
end
if created_accounts.any?
# Reload to pick up the newly created account_provider associations
traderepublic_item.reload
# Process transactions immediately for the newly linked accounts
# This creates Entry records from the raw transaction data
traderepublic_item.process_accounts
# Trigger full sync in background to update balances and get latest data
traderepublic_item.sync_later
# Redirect to the newly created account if single account, or accounts list if multiple
# Avoid redirecting back to /accounts/new
redirect_path = if return_to == new_account_path || return_to.blank?
created_accounts.size == 1 ? account_path(created_accounts.first) : accounts_path
else
return_to
end
redirect_to redirect_path, notice: t(".accounts_linked",
count: created_accounts.count,
default: "Successfully linked %{count} Trade Republic account(s)")
elsif already_linked_accounts.any?
redirect_to return_to, alert: t(".accounts_already_linked",
default: "Selected accounts are already linked")
else
redirect_to new_account_path, alert: t(".no_valid_accounts", default: "No valid accounts to link")
end
end
def edit
render layout: false
end
def update
if @traderepublic_item.update(traderepublic_item_params)
redirect_to traderepublic_items_path, notice: t(".updated", default: "Trade Republic connection updated successfully")
else
render :edit, status: :unprocessable_entity, layout: false
end
end
def destroy
@traderepublic_item.destroy_later
respond_to do |format|
format.turbo_stream do
flash.now[:notice] = t(".scheduled_for_deletion", default: "Trade Republic connection scheduled for deletion")
render turbo_stream: [
turbo_stream.remove("traderepublic-item-#{@traderepublic_item.id}"),
turbo_stream.update("flash", partial: "shared/flash")
]
end
format.html do
redirect_to traderepublic_items_path, notice: t(".scheduled_for_deletion", default: "Trade Republic connection scheduled for deletion")
end
end
end
def sync
@traderepublic_item.sync_later
respond_to do |format|
format.turbo_stream do
flash.now[:notice] = t(".sync_started", default: "Sync started")
render turbo_stream: turbo_stream.replace(
"traderepublic-providers-panel",
partial: "settings/providers/traderepublic_panel"
)
end
format.html do
redirect_to traderepublic_items_path, notice: t(".sync_started", default: "Sync started")
end
end
end
def reauthenticate
Rails.logger.info "TradeRepublic reauthenticate action called"
Rails.logger.info "Request format: #{request.format}"
Rails.logger.info "Turbo frame: #{request.headers['Turbo-Frame']}"
begin
result = @traderepublic_item.initiate_login!
Rails.logger.info "Login initiated successfully"
respond_to do |format|
format.turbo_stream do
Rails.logger.info "Rendering turbo_stream response"
render turbo_stream: turbo_stream.update(
"modal",
partial: "traderepublic_items/verify_pin",
locals: { traderepublic_item: @traderepublic_item }
)
end
format.html do
redirect_to verify_pin_traderepublic_item_path(@traderepublic_item),
notice: t(".device_pin_sent", default: "Please check your phone for the verification PIN")
end
end
rescue TraderepublicError => e
Rails.logger.error "TradeRepublic re-authentication initiation failed: #{e.message}"
respond_to do |format|
format.turbo_stream do
flash.now[:alert] = t(".login_failed", default: "Re-authentication failed: #{e.message}")
render turbo_stream: turbo_stream.replace(
"traderepublic-providers-panel",
partial: "settings/providers/traderepublic_panel"
)
end
format.html do
redirect_to traderepublic_items_path, alert: t(".login_failed", default: "Re-authentication failed: #{e.message}")
end
end
end
end
# For existing account linking (when adding provider to existing account)
def select_existing_account
begin
@account = Current.family.accounts.find(params[:account_id])
rescue ActiveRecord::RecordNotFound
redirect_to new_account_path, alert: t(".account_not_found", default: "Account not found")
return
end
@accountable_type = @account.accountable_type
# Get the most recent traderepublic_item with valid session
@traderepublic_item = Current.family.traderepublic_items
.where.not(session_token: nil)
.where(status: :good)
.order(updated_at: :desc)
.first
unless @traderepublic_item
redirect_to new_traderepublic_item_path, alert: t(".no_active_connection")
return
end
# Get available accounts (unlinked only)
@available_accounts = @traderepublic_item.traderepublic_accounts
.where.not(id: AccountProvider.where(provider_type: "TraderepublicAccount").select(:provider_id))
render layout: false
end
# Link existing account
def link_existing_account
begin
account = Current.family.accounts.find(params[:account_id])
rescue ActiveRecord::RecordNotFound
redirect_to new_account_path, alert: t(".account_not_found", default: "Account not found")
return
end
traderepublic_account_id = params[:traderepublic_account_id]
if traderepublic_account_id.blank?
redirect_to account_path(account), alert: t(".no_account_selected")
return
end
begin
traderepublic_account = Current.family.traderepublic_accounts.find(traderepublic_account_id)
rescue ActiveRecord::RecordNotFound
redirect_to new_account_path, alert: t(".traderepublic_account_not_found", default: "Trade Republic account not found")
return
end
# Check if already linked
if traderepublic_account.account_provider.present?
redirect_to account_path(account), alert: t(".already_linked")
return
end
# Create the link
AccountProvider.create!(
account: account,
provider: traderepublic_account
)
# Trigger sync
traderepublic_account.traderepublic_item.sync_later
redirect_to account_path(account), notice: t(".linked_successfully", default: "Trade Republic account linked successfully")
end
private
def set_traderepublic_item
@traderepublic_item = Current.family.traderepublic_items.find(params[:id])
end
def traderepublic_item_params
params.fetch(:traderepublic_item, {}).permit(:name, :phone_number, :pin)
end
def safe_return_to_path
return_to_raw = params[:return_to].to_s
return new_account_path if return_to_raw.blank?
decoded = CGI.unescape(return_to_raw)
begin
uri = URI.parse(decoded)
rescue URI::InvalidURIError
return new_account_path
end
# Only allow local paths: no scheme, no host, starts with a single leading slash (not protocol-relative //)
path = uri.path || decoded
if uri.scheme.nil? && uri.host.nil? && path.start_with?("/") && !path.start_with?("//")
# Rebuild path with query and fragment if present
built = path
built += "?#{uri.query}" if uri.query.present?
built += "##{uri.fragment}" if uri.fragment.present?
return built
end
new_account_path
end
end

View File

@@ -0,0 +1,21 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["buttonText", "spinner"]
submit(event) {
// Don't prevent default - let the form submit
// Show spinner and update text
if (this.hasButtonTextTarget) {
this.buttonTextTarget.textContent = "Sending code..."
}
if (this.hasSpinnerTarget) {
this.spinnerTarget.classList.remove("hidden")
}
// Disable the button to prevent double-clicks
event.currentTarget.disabled = true
}
}

View File

@@ -0,0 +1,8 @@
class TraderepublicItem::SyncJob < ApplicationJob
queue_as :high_priority
def perform(sync)
Rails.logger.info "TraderepublicItem::SyncJob: Starting sync for item \\#{sync.syncable_id} (Sync ##{sync.id})"
sync.perform
end
end

View File

@@ -551,8 +551,9 @@ class Account::ProviderImportAdapter
# @param external_id [String, nil] Provider's unique ID (optional, for deduplication)
# @param source [String] Provider name
# @param activity_label [String, nil] Investment activity label (e.g., "Buy", "Sell", "Reinvestment")
# @param trade_type [String, nil] Optional trade type override for TradeRepublic naming
# @return [Entry] The created entry with trade
def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil)
def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil, trade_type: nil)
raise ArgumentError, "security is required" if security.nil?
raise ArgumentError, "source is required" if source.blank?
@@ -561,8 +562,14 @@ class Account::ProviderImportAdapter
trade_name = if name.present?
name
else
trade_type = quantity.negative? ? "sell" : "buy"
Trade.build_name(trade_type, quantity, security.ticker)
# Only use trade_type if source is traderepublic and trade_type is present
effective_type =
if source == "traderepublic" && trade_type.present?
trade_type
else
quantity.negative? ? "sell" : "buy"
end
Trade.build_name(effective_type, quantity, security.ticker)
end
# Use find_or_initialize_by with external_id if provided, otherwise create new

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
module TraderepublicSessionConfigurable
extend ActiveSupport::Concern
included do
def ensure_session_configured!
raise "Session not configured" unless traderepublic_item.session_configured?
end
end
end

View File

@@ -1,5 +1,5 @@
class DataEnrichment < ApplicationRecord
belongs_to :enrichable, polymorphic: true
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital" }
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", traderepublic: "traderepublic", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital" }
end

View File

@@ -1,6 +1,6 @@
class Family < ApplicationRecord
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, TraderepublicConnectable
include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
include IndexaCapitalConnectable

View File

@@ -0,0 +1,28 @@
module Family::TraderepublicConnectable
extend ActiveSupport::Concern
included do
has_many :traderepublic_items, dependent: :destroy
end
def can_connect_traderepublic?
# Families can configure their own Trade Republic credentials
true
end
def create_traderepublic_item!(phone_number:, pin:, item_name: nil)
traderepublic_item = traderepublic_items.create!(
name: item_name || "Trade Republic Connection",
phone_number: phone_number,
pin: pin
)
traderepublic_item.sync_later
traderepublic_item
end
def has_traderepublic_credentials?
traderepublic_items.where.not(phone_number: nil).exists?
end
end

View File

@@ -22,7 +22,32 @@ class Holding::ForwardCalculator
current_portfolio = next_portfolio
end
Holding.gapfill(holdings)
# Also include the first date where qty = 0 for each security (position closed)
valid_holdings = []
holdings.group_by(&:security_id).each do |security_id, sec_holdings|
sorted = sec_holdings.sort_by(&:date)
prev_qty = nil
sorted.each do |h|
# Note: this condition (h.qty.to_f > 0 && h.amount.to_f > 0)
# intentionally filters out holdings where quantity > 0 but amount == 0
# (for example when price is missing or zero). If zero-amount records
# should be treated as valid, consider falling back to a price lookup
# or include qty>0 entries and compute amount from a known price.
if h.qty.to_f > 0 && h.amount.to_f > 0
valid_holdings << h
elsif h.qty.to_f == 0
if prev_qty.nil?
# Allow initial zero holding (initial portfolio state)
valid_holdings << h
elsif prev_qty > 0
# Add the first date where qty = 0 after a sequence of qty > 0 (position closure)
valid_holdings << h
end
end
prev_qty = h.qty.to_f
end
end
Holding.gapfill(valid_holdings)
end
end

View File

@@ -0,0 +1,712 @@
require "websocket-client-simple"
require "json"
class Provider::Traderepublic
# Batch fetch instrument details for a list of ISINs
# Returns a hash { isin => instrument_details }
def batch_fetch_instrument_details(isins)
results = {}
batch_websocket_calls do |batch|
isins.uniq.each do |isin|
results[isin] = batch.get_instrument_details(isin)
end
end
results
end
# Helper: Get portfolio, cash et available_cash en un seul batch WebSocket
def get_portfolio_and_cash_batch
results = {}
batch_websocket_calls do |batch|
results[:portfolio] = batch.get_portfolio
results[:cash] = batch.get_cash
results[:available_cash] = batch.get_available_cash
end
results
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
def get_portfolio
@provider.subscribe_once("compactPortfolioByType")
end
def get_cash
@provider.subscribe_once("cash")
end
def get_available_cash
@provider.subscribe_once("availableCash")
end
def get_timeline_detail(id)
@provider.subscribe_once("timelineDetailV2", { id: id })
end
def get_instrument_details(isin)
@provider.subscribe_once("instrument", { id: isin })
end
# Ajoutez ici d'autres helpers si besoin
end
include HTTParty
headers "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
HOST = "https://api.traderepublic.com".freeze
WS_HOST = "wss://api.traderepublic.com".freeze
WS_CONNECT_VERSION = "31".freeze
ECHO_INTERVAL = 30 # seconds
WS_CONNECTION_TIMEOUT = 10 # seconds
SESSION_VALIDATION_TIMEOUT = 7 # seconds
attr_reader :phone_number, :pin
attr_accessor :session_token, :refresh_token, :raw_cookies, :process_id, :jsessionid
def initialize(phone_number:, pin:, session_token: nil, refresh_token: nil, raw_cookies: nil)
@phone_number = phone_number
@pin = pin
@session_token = session_token
@refresh_token = refresh_token
@raw_cookies = raw_cookies || []
@process_id = nil
@jsessionid = nil
@ws = nil
@subscriptions = {}
@next_subscription_id = 1
@echo_thread = nil
@connected = false
@mutex = Mutex.new
end
# Authentication - Step 1: Initial login to get processId
def initiate_login
payload = {
phoneNumber: @phone_number,
pin: @pin
}
Rails.logger.info "TradeRepublic: Initiating login for phone: #{@phone_number.to_s.gsub(/\d(?=\d{4})/, '*')}"
sanitized_payload = payload.dup
if sanitized_payload[:phoneNumber]
sanitized_payload[:phoneNumber] = sanitized_payload[:phoneNumber].to_s.gsub(/\d(?=\d{4})/, '*')
end
sanitized_payload[:pin] = '[FILTERED]' if sanitized_payload.key?(:pin)
Rails.logger.debug "TradeRepublic: Request payload: #{sanitized_payload.to_json}"
response = self.class.post(
"#{HOST}/api/v1/auth/web/login",
headers: default_headers,
body: payload.to_json
)
Rails.logger.info "TradeRepublic: Login response status: #{response.code}"
Rails.logger.debug "TradeRepublic: Login response body: #{response.body}"
Rails.logger.debug "TradeRepublic: Login response headers: #{response.headers.inspect}"
# Extract and store JSESSIONID cookie for subsequent requests
if response.headers["set-cookie"]
set_cookies = response.headers["set-cookie"]
set_cookies = [set_cookies] unless set_cookies.is_a?(Array)
set_cookies.each do |cookie|
if cookie.start_with?("JSESSIONID=")
@jsessionid = cookie.split(";").first
Rails.logger.info "TradeRepublic: JSESSIONID extracted: #{@jsessionid}"
break
end
end
end
handle_http_response(response)
rescue => e
Rails.logger.error "TradeRepublic: Initial login failed - #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if e.respond_to?(:backtrace)
raise TraderepublicError.new("Login initiation failed: #{e.message}", :login_failed)
end
# Authentication - Step 2: Verify device PIN
def verify_device_pin(device_pin)
raise TraderepublicError.new("No processId available", :invalid_state) unless @process_id
url = "#{HOST}/api/v1/auth/web/login/#{@process_id}/#{device_pin}"
headers = default_headers
# Include JSESSIONID cookie if available
if @jsessionid
headers["Cookie"] = @jsessionid
Rails.logger.info "TradeRepublic: Including JSESSIONID in verification request"
end
Rails.logger.info "TradeRepublic: Verifying device PIN for processId: #{@process_id}"
Rails.logger.debug "TradeRepublic: Verification URL: #{url}"
Rails.logger.debug "TradeRepublic: Verification headers: #{headers.inspect}"
# IMPORTANT: Use POST, not GET!
response = self.class.post(
url,
headers: headers
)
Rails.logger.info "TradeRepublic: PIN verification response status: #{response.code}"
Rails.logger.debug "TradeRepublic: PIN verification response body: #{response.body}"
Rails.logger.debug "TradeRepublic: PIN verification response headers: #{response.headers.inspect}"
if response.success?
extract_cookies_from_response(response)
Rails.logger.info "TradeRepublic: Session token extracted: #{@session_token ? 'YES' : 'NO'}"
Rails.logger.info "TradeRepublic: Refresh token extracted: #{@refresh_token ? 'YES' : 'NO'}"
@session_token || raise(TraderepublicError.new("Session token not found after verification", :auth_failed))
else
handle_http_response(response)
end
rescue TraderepublicError
raise
rescue => e
Rails.logger.error "TradeRepublic: Device PIN verification failed - #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if e.respond_to?(:backtrace)
raise TraderepublicError.new("PIN verification failed: #{e.message}", :verification_failed)
end
# Full login flow with device PIN callback
def login(&device_pin_callback)
return true if session_valid?
# Step 1: Initiate login
result = initiate_login
@process_id = result["processId"]
# Step 2: Get device PIN from user
device_pin = device_pin_callback.call
# Step 3: Verify device PIN
verify_device_pin(device_pin)
true
rescue => e
Rails.logger.error "TradeRepublic: Full login failed - #{e.message}"
false
end
# Check if we have a valid session
def session_valid?
return false unless @session_token
# We'll validate by trying to connect to WebSocket
# This is a simple check - real validation would require a test subscription
@session_token.present?
end
# Refresh session token using refresh_token
def refresh_session
unless @refresh_token
Rails.logger.error "TradeRepublic: Cannot refresh session - no refresh token available"
return false
end
Rails.logger.info "TradeRepublic: Refreshing session token"
# Try the refresh endpoint first
response = self.class.post(
"#{HOST}/api/v1/auth/refresh",
headers: default_headers.merge(cookie_header),
body: { refreshToken: @refresh_token }.to_json
)
Rails.logger.info "TradeRepublic: Token refresh response status: #{response.code}"
Rails.logger.debug "TradeRepublic: Token refresh response body: #{response.body}"
if response.success?
extract_cookies_from_response(response)
Rails.logger.info "TradeRepublic: Session token refreshed: #{@session_token ? 'YES' : 'NO'}"
return true
end
# If refresh endpoint doesn't work (404 or error), try alternate approach
# Some APIs require re-authentication instead of refresh
if response.code == 404 || response.code >= 400
Rails.logger.warn "TradeRepublic: Refresh endpoint not available (#{response.code}), re-authentication required"
return false
end
false
rescue => e
Rails.logger.error "TradeRepublic: Token refresh error - #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if e.respond_to?(:backtrace)
false
end
# WebSocket operations
def connect_websocket
raise "Already connected" if @ws && @ws.open?
# Store reference to self for use in closures
provider = self
@ws = WebSocket::Client::Simple.connect(WS_HOST) do |ws|
ws.on :open do
Rails.logger.info "TradeRepublic: WebSocket opened"
# Send connect message with proper configuration
connect_msg = {
locale: "fr",
platformId: "webtrading",
platformVersion: "safari - 18.3.0",
clientId: "app.traderepublic.com",
clientVersion: "3.151.3"
}
ws.send("connect #{WS_CONNECT_VERSION} #{connect_msg.to_json}")
Rails.logger.info "TradeRepublic: Sent connect message, waiting for confirmation..."
end
ws.on :message do |msg|
Rails.logger.debug "TradeRepublic: WebSocket received message: #{msg.data.to_s.inspect[0..200]}"
# Mark as connected when we receive the "connected" response
if msg.data.start_with?("connected")
Rails.logger.info "TradeRepublic: WebSocket confirmed connected"
provider.instance_variable_set(:@connected, true)
provider.send(:start_echo_thread)
end
provider.send(:handle_websocket_message, msg.data)
end
ws.on :close do |e|
code = e.respond_to?(:code) ? e.code : "unknown"
reason = e.respond_to?(:reason) ? e.reason : "unknown"
Rails.logger.info "TradeRepublic: WebSocket closed - Code: #{code}, Reason: #{reason}"
provider.instance_variable_set(:@connected, false)
thread = provider.instance_variable_get(:@echo_thread)
thread&.kill
provider.instance_variable_set(:@echo_thread, nil)
end
ws.on :error do |e|
Rails.logger.error "TradeRepublic: WebSocket error - #{e.message}"
provider.instance_variable_set(:@connected, false)
end
end
# Wait for connection
wait_for_connection
end
def disconnect_websocket
return unless @ws
if @echo_thread
@echo_thread.kill
@echo_thread = nil
end
if @ws.open?
@ws.close
end
@ws = nil
@connected = false
end
# Subscribe to a message type
def subscribe(message_type, params = {}, &callback)
raise "Not connected" unless @connected
sub_id = @next_subscription_id
@next_subscription_id += 1
message = build_message(message_type, params)
@mutex.synchronize do
@subscriptions[sub_id] = {
type: message_type,
callback: callback,
message: message
}
end
send_subscription(sub_id, message)
sub_id
end
# Unsubscribe from a subscription
def unsubscribe(sub_id)
@mutex.synchronize do
@subscriptions.delete(sub_id)
end
@ws&.send("unsub #{sub_id}") if @connected
end
# Subscribe once (callback will be removed after first message)
def subscribe_once(message_type, params = {})
result = nil
error = nil
sub_id = subscribe(message_type, params) do |data|
result = data
unsubscribe(sub_id)
end
# Wait for result (with timeout)
timeout = Time.now + SESSION_VALIDATION_TIMEOUT
while result.nil? && Time.now < timeout
sleep 0.1
# Check if an error was stored in the subscription
subscription = nil
@mutex.synchronize do
subscription = @subscriptions[sub_id]
end
if subscription && subscription[:error]
error = subscription[:error]
# Call unsubscribe outside the mutex (unsubscribe already synchronizes)
unsubscribe(sub_id)
break
end
end
# Raise the error if one occurred
raise error if error
if result
parsed = JSON.parse(result)
# Handle double-encoded JSON (some TR responses are JSON strings containing JSON)
if parsed.is_a?(String) && (parsed.start_with?("{") || parsed.start_with?("["))
begin
parsed = JSON.parse(parsed)
rescue JSON::ParserError
# Keep as string if it's not valid JSON
end
end
parsed
else
nil
end
end
# Helper: Get portfolio data
def get_portfolio
with_websocket_connection do
subscribe_once("compactPortfolioByType")
end
end
# Helper: Get cash data
def get_cash
with_websocket_connection do
subscribe_once("cash")
end
end
# Helper: Get available cash
def get_available_cash
with_websocket_connection do
subscribe_once("availableCash")
end
end
# Helper: Get timeline transactions (with automatic pagination)
# @param since [Date, nil] Only fetch transactions after this date (for incremental sync)
# Returns aggregated data from all pages in the same format as a single page response
def get_timeline_transactions(since: nil)
if since
Rails.logger.info "TradeRepublic: Fetching timeline transactions since #{since} (incremental sync)"
else
Rails.logger.info "TradeRepublic: Fetching all timeline transactions (full sync)"
end
all_items = []
page_num = 1
cursor_after = nil
max_pages = 100 # Safety limit to prevent infinite loops
reached_since_date = false
begin
connect_websocket
loop do
break if page_num > max_pages
break if reached_since_date
params = cursor_after ? { after: cursor_after } : {}
response_data = subscribe_once("timelineTransactions", params)
break unless response_data
items = response_data.dig("items") || []
if since
items_to_add = []
items.each do |item|
timestamp_str = item.dig("timestamp")
if timestamp_str
item_date = DateTime.parse(timestamp_str).to_date
if item_date > since
items_to_add << item
else
reached_since_date = true
break
end
else
items_to_add << item
end
end
all_items.concat(items_to_add)
else
all_items.concat(items)
end
break if reached_since_date
cursors = response_data.dig("cursors") || {}
cursor_after = cursors["after"]
break if cursor_after.nil? || cursor_after.empty?
page_num += 1
sleep 0.3
end
ensure
disconnect_websocket
end
# Batch fetch instrument details for all ISINs in transactions
isins = all_items.map { |item| item["isin"] }.compact.uniq
instrument_details = batch_fetch_instrument_details(isins) unless isins.empty?
# Ajoute les détails instrument à chaque transaction
if instrument_details
all_items.each do |item|
isin = item["isin"]
item["instrument_details"] = instrument_details[isin] if isin && instrument_details[isin]
end
end
{
"items" => all_items,
"cursors" => {},
"startingTransactionId" => nil
}
rescue => e
Rails.logger.error "TradeRepublic: Failed to fetch timeline transactions - #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
nil
end
# Helper: Get timeline detail
def get_timeline_detail(id)
with_websocket_connection do
subscribe_once("timelineDetailV2", { id: id })
end
end
# Helper: Get instrument details (name, description, etc.) by ISIN
def get_instrument_details(isin)
with_websocket_connection do
subscribe_once("instrument", { id: isin })
end
end
# Execute block with WebSocket connection
def with_websocket_connection
begin
connect_websocket
result = yield
sleep 0.5 # Give time for any pending messages
result
rescue => e
Rails.logger.error "TradeRepublic WebSocket error: #{e.message}"
raise
ensure
disconnect_websocket
end
end
private
def default_headers
{
"Content-Type" => "application/json",
"Accept" => "application/json",
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
"Origin" => "https://app.traderepublic.com",
"Referer" => "https://app.traderepublic.com/",
"Accept-Language" => "en",
"x-tr-platform" => "web",
"x-tr-app-version" => "12.12.0"
}
end
def cookie_header
return {} if @raw_cookies.nil? || @raw_cookies.empty?
# Join all cookies into a single Cookie header
cookie_string = @raw_cookies.map do |cookie|
# Extract just the name=value part before the first semicolon
cookie.split(";").first
end.join("; ")
{ "Cookie" => cookie_string }
end
def extract_cookies_from_response(response)
# Extract Set-Cookie headers
set_cookie_headers = response.headers["set-cookie"]
if set_cookie_headers
@raw_cookies = set_cookie_headers.is_a?(Array) ? set_cookie_headers : [ set_cookie_headers ]
# Extract session and refresh tokens
@session_token = extract_cookie_value("tr_session")
@refresh_token = extract_cookie_value("tr_refresh")
end
end
def extract_cookie_value(name)
@raw_cookies.each do |cookie|
match = cookie.match(/#{name}=([^;]+)/)
return match[1] if match
end
nil
end
def wait_for_connection
timeout = Time.now + WS_CONNECTION_TIMEOUT
until @connected || Time.now > timeout
sleep 0.1
end
raise TraderepublicError.new("WebSocket connection timeout", :connection_timeout) unless @connected
end
def start_echo_thread
@echo_thread = Thread.new do
loop do
sleep ECHO_INTERVAL
break unless @connected
send_echo
end
end
end
def send_echo
@ws&.send("echo #{Time.now.to_i * 1000}")
rescue => e
Rails.logger.warn "TradeRepublic: Failed to send echo - #{e.message}"
end
def handle_websocket_message(raw_message)
return if raw_message.start_with?("echo") || raw_message.start_with?("connected")
parsed = parse_websocket_payload(raw_message)
return unless parsed
sub_id = parsed[:subscription_id]
json_string = parsed[:json_data]
begin
data = JSON.parse(json_string)
rescue JSON::ParserError
Rails.logger.error "TradeRepublic: Failed to parse WebSocket message JSON"
return
end
# Check for authentication errors
if data.is_a?(Hash) && data["errors"]
auth_error = data["errors"].find { |err| err["errorCode"] == "AUTHENTICATION_ERROR" }
if auth_error
Rails.logger.error "TradeRepublic: Authentication error received - #{auth_error['errorMessage']}"
# Store error for the subscription callback
if sub_id && @subscriptions[sub_id]
@subscriptions[sub_id][:error] = TraderepublicError.new(auth_error["errorMessage"] || "Unauthorized", :auth_failed)
end
end
end
return unless sub_id
subscription = @subscriptions[sub_id]
if subscription
begin
# If there's an error stored, raise it
raise subscription[:error] if subscription[:error]
subscription[:callback].call(json_string)
rescue => e
Rails.logger.error "TradeRepublic: Subscription callback error - #{e.message}"
raise if e.is_a?(TraderepublicError) # Re-raise TraderepublicError to propagate auth failures
end
end
end
def parse_websocket_payload(raw_message)
# Find the first occurrence of { or [
start_index_obj = raw_message.index("{")
start_index_arr = raw_message.index("[")
start_index = if start_index_obj && start_index_arr
[start_index_obj, start_index_arr].min
elsif start_index_obj
start_index_obj
elsif start_index_arr
start_index_arr
else
nil
end
return nil unless start_index
id_part = raw_message[0...start_index].strip
id_match = id_part.match(/\d+/)
subscription_id = id_match ? id_match[0].to_i : nil
json_data = raw_message[start_index..-1].strip
{ subscription_id: subscription_id, json_data: json_data }
end
def build_message(type, params = {})
{ type: type, token: @session_token }.merge(params)
end
def send_subscription(sub_id, message)
payload = "sub #{sub_id} #{message.to_json}"
@ws.send(payload)
end
def handle_http_response(response)
Rails.logger.error "TradeRepublic: HTTP response code=#{response.code}, body=#{response.body}"
case response.code
when 200
JSON.parse(response.body)
when 400
raise TraderepublicError.new("Bad request: #{response.body}", :bad_request)
when 401
raise TraderepublicError.new("Invalid credentials", :unauthorized)
when 403
raise TraderepublicError.new("Access forbidden", :forbidden)
when 404
raise TraderepublicError.new("Resource not found", :not_found)
when 429
raise TraderepublicError.new("Rate limit exceeded", :rate_limit_exceeded)
when 500..599
raise TraderepublicError.new("Server error: #{response.code}", :server_error)
else
raise TraderepublicError.new("Unexpected response: #{response.code}", :unexpected_response)
end
end
end

View File

@@ -0,0 +1,89 @@
class Provider::TraderepublicAdapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
# Register this adapter with the factory
Provider::Factory.register("TraderepublicAccount", self)
# Define which account types this provider supports
def self.supported_account_types
%w[Investment Depository]
end
# Returns connection configurations for this provider
def self.connection_configs(family:)
return [] unless family.can_connect_traderepublic?
[ {
key: "traderepublic",
name: I18n.t("traderepublic_items.provider_name", default: "Trade Republic"),
description: I18n.t("traderepublic_items.provider_description", default: "Connect to your Trade Republic account"),
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.select_accounts_traderepublic_items_path(
accountable_type: accountable_type,
return_to: return_to
)
},
existing_account_path: ->(account_id) {
Rails.application.routes.url_helpers.select_existing_account_traderepublic_items_path(
account_id: account_id
)
}
} ]
end
def provider_name
"traderepublic"
end
# Build a Trade Republic provider instance with family-specific credentials
# @param family [Family] The family to get credentials for (required)
# @return [Provider::Traderepublic, nil] Returns nil if credentials are not configured
def self.build_provider(family: nil)
return nil unless family.present?
# Get family-specific credentials
traderepublic_item = family.traderepublic_items.where.not(phone_number: nil).first
return nil unless traderepublic_item&.credentials_configured?
Provider::Traderepublic.new(
phone_number: traderepublic_item.phone_number,
pin: traderepublic_item.pin
)
end
def sync_path
Rails.application.routes.url_helpers.sync_traderepublic_item_path(item)
end
def item
provider_account.traderepublic_item
end
def can_delete_holdings?
false
end
def institution_domain
"traderepublic.com"
end
def institution_name
I18n.t("traderepublic_items.provider_name", default: "Trade Republic")
end
def institution_url
"https://traderepublic.com"
end
def institution_color
"#00D69E"
end
private
def provider_account
@provider_account ||= TraderepublicAccount.find(@account_provider.provider_id)
end
end

View File

@@ -0,0 +1,39 @@
# Centralizes logic for resolving, associating, or creating a Security for TradeRepublic
class TradeRepublic::SecurityResolver
def initialize(isin, name: nil, ticker: nil, mic: nil)
@isin = isin&.strip&.upcase
@name = name
@ticker = ticker
@mic = mic
end
# Returns the existing Security or creates a new one if not found
def resolve
Rails.logger.info "TradeRepublic::SecurityResolver - Resolve called: ISIN=#{@isin.inspect}, name=#{@name.inspect}, ticker=#{@ticker.inspect}, mic=#{@mic.inspect}"
return nil unless @isin.present?
# Search for an exact ISIN match in the name
security = Security.where("name LIKE ?", "%#{@isin}%").first
if security
Rails.logger.info "TradeRepublic::SecurityResolver - Security found by ISIN in name: id=#{security.id}, ISIN=#{@isin}, name=#{security.name.inspect}, ticker=#{security.ticker.inspect}, mic=#{security.exchange_operating_mic.inspect}"
return security
end
# Create a new Security if none found
name = @name.present? ? @name : "Security #{@isin}"
name = "#{name} (#{@isin})" unless name.include?(@isin)
begin
security = Security.create!(name: name, ticker: @ticker, exchange_operating_mic: @mic)
Rails.logger.info "TradeRepublic::SecurityResolver - Security created: id=#{security.id}, ISIN=#{@isin}, ticker=#{@ticker}, mic=#{@mic}, name=#{name.inspect}"
security
rescue ActiveRecord::RecordInvalid => e
if e.message.include?("Ticker has already been taken")
existing = Security.where(ticker: @ticker, exchange_operating_mic: @mic).first
Rails.logger.warn "TradeRepublic::SecurityResolver - Duplicate ticker/mic, returning existing: id=#{existing&.id}, ticker=#{@ticker}, mic=#{@mic}"
return existing if existing
end
raise
end
end
end

View File

@@ -0,0 +1,72 @@
class TraderepublicAccount < ApplicationRecord
# Stocke le snapshot brut du compte (portfolio)
def upsert_traderepublic_snapshot!(account_snapshot)
self.raw_payload = account_snapshot
save!
end
belongs_to :traderepublic_item
has_one :account_provider, as: :provider, dependent: :destroy
has_one :linked_account, through: :account_provider, source: :account
# Stocke le snapshot brut des transactions (timeline enrichie)
def upsert_traderepublic_transactions_snapshot!(transactions_snapshot)
Rails.logger.info "TraderepublicAccount #{id}: upsert_traderepublic_transactions_snapshot! - snapshot keys=#{transactions_snapshot.is_a?(Hash) ? transactions_snapshot.keys : transactions_snapshot.class}"
Rails.logger.info "TraderepublicAccount \\#{id}: upsert_traderepublic_transactions_snapshot! - snapshot preview=\\#{transactions_snapshot.inspect[0..300]}"
# If the new snapshot is nil or empty, do not overwrite existing payload
if transactions_snapshot.nil? || (transactions_snapshot.respond_to?(:empty?) && transactions_snapshot.empty?)
Rails.logger.info "TraderepublicAccount #{id}: Received empty transactions snapshot, skipping overwrite."
return
end
# If this is the first import or there is no existing payload, just set it
if self.raw_transactions_payload.nil? || (self.raw_transactions_payload.respond_to?(:empty?) && self.raw_transactions_payload.empty?)
self.raw_transactions_payload = transactions_snapshot
save!
return
end
# Merge/append new transactions to existing payload (assuming array of items under 'items' key)
existing = self.raw_transactions_payload
new_data = transactions_snapshot
# Support both Hash and Array structures (prefer Hash with 'items')
existing_items = if existing.is_a?(Hash) && existing["items"].is_a?(Array)
existing["items"]
elsif existing.is_a?(Array)
existing
else
[]
end
new_items = if new_data.is_a?(Hash) && new_data["items"].is_a?(Array)
new_data["items"]
elsif new_data.is_a?(Array)
new_data
else
[]
end
# Only append items that are not already present (by id if available)
existing_ids = existing_items.map { |i| i["id"] }.compact
items_to_add = new_items.reject { |i| i["id"] && existing_ids.include?(i["id"]) }
merged_items = existing_items + items_to_add
# Rebuild the payload in the same structure as before
merged_payload = if existing.is_a?(Hash)
existing.merge("items" => merged_items)
else
merged_items
end
self.raw_transactions_payload = merged_payload
save!
end
# Pour compatibilité avec l'importer
def last_transaction_date
return nil unless linked_account && linked_account.transactions.any?
linked_account.transactions.order(date: :desc).limit(1).pick(:date)
end
end

View File

@@ -0,0 +1,593 @@
class TraderepublicAccount::Processor
attr_reader :traderepublic_account
def initialize(traderepublic_account)
@traderepublic_account = traderepublic_account
end
def process
account = traderepublic_account.linked_account
return unless account
# Wrap deletions in a transaction so trades and Entry deletions succeed or roll back together
Account.transaction do
if account.respond_to?(:trades)
deleted_count = account.trades.delete_all
Rails.logger.info "TraderepublicAccount::Processor - #{deleted_count} trades for account ##{account.id} deleted before reprocessing."
end
Entry.where(account_id: account.id, source: "traderepublic").delete_all
Rails.logger.info "TraderepublicAccount::Processor - All Entry records for account ##{account.id} deleted before reprocessing."
end
Rails.logger.info "TraderepublicAccount::Processor - Processing account #{account.id}"
# Process transactions from raw payload
process_transactions(account)
# Process holdings from raw payload (calculate, then persist)
begin
Holding::Materializer.new(account, strategy: :forward).materialize_holdings
Rails.logger.info "TraderepublicAccount::Processor - Holdings calculated and persisted."
rescue => e
Rails.logger.error "TraderepublicAccount::Processor - Error calculating/persisting holdings: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
end
# Persist balances using Balance::Materializer (strategy: :forward)
begin
Balance::Materializer.new(account, strategy: :forward).materialize_balances
Rails.logger.info "TraderepublicAccount::Processor - Balances calculated and persisted."
rescue => e
Rails.logger.error "TraderepublicAccount::Processor - Error in Balance::Materializer: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
end
Rails.logger.info "TraderepublicAccount::Processor - Finished processing account #{account.id}"
end
private
def process_transactions(account)
transactions_data = traderepublic_account.raw_transactions_payload
return unless transactions_data
Rails.logger.info "[TR Processor] transactions_data loaded: #{transactions_data.class}"
# Extract items array from the payload structure
# Try both Hash and Array formats
items = if transactions_data.is_a?(Hash)
transactions_data["items"]
elsif transactions_data.is_a?(Array)
transactions_data.find { |pair| pair[0] == "items" }&.last
end
return unless items.is_a?(Array)
Rails.logger.info "[TR Processor] items array size: #{items.size}"
Rails.logger.info "TraderepublicAccount::Processor - Processing #{items.size} transactions"
items.each do |txn|
Rails.logger.info "[TR Processor] Processing txn id=#{txn['id']}"
process_single_transaction(account, txn)
end
Rails.logger.info "TraderepublicAccount::Processor - Finished processing transactions"
end
def process_single_transaction(account, txn)
# Skip if deleted or hidden
if txn["deleted"]
Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (deleted)"
return
end
if txn["hidden"]
Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (hidden)"
return
end
unless txn["status"] == "EXECUTED"
Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (status=#{txn['status']})"
return
end
# Parse basic data
traderepublic_id = txn["id"]
title = txn["title"]
subtitle = txn["subtitle"]
amount_data = txn["amount"] || {}
amount = amount_data["value"]
currency = amount_data["currency"] || "EUR"
timestamp = txn["timestamp"]
unless traderepublic_id && timestamp && amount
Rails.logger.info "[TR Processor] Skipping txn: missing traderepublic_id, timestamp, or amount (id=#{txn['id']})"
return
end
# Trade Republic sends negative values for expenses (Buys) and positive values for income (Sells).
# Sure expects negative = income and positive = expense, so we invert the sign here.
amount = -amount.to_f
# Parse date
begin
date = Time.parse(timestamp).to_date
rescue StandardError => e
Rails.logger.warn "TraderepublicAccount::Processor - Failed to parse timestamp #{timestamp.inspect} for txn #{traderepublic_id}: #{e.class}: #{e.message}. Falling back to Date.today"
date = Date.today
end
# Check if this is a trade (Buy/Sell Order)
# Note: subtitle contains the trade type info that becomes 'notes' after import
is_trade_result = is_trade?(subtitle)
Rails.logger.info "TradeRepublic: Processing '#{title}' | Subtitle: '#{subtitle}' | is_trade?: #{is_trade_result}"
if is_trade_result
Rails.logger.info "[TR Processor] Transaction id=#{traderepublic_id} is a trade."
process_trade(traderepublic_id, title, subtitle, amount, currency, date, txn)
else
Rails.logger.info "[TR Processor] Transaction id=#{traderepublic_id} is NOT a trade. Importing as cash transaction."
# Import cash transactions (dividends, interest, transfers)
import_adapter.import_transaction(
external_id: traderepublic_id,
amount: amount,
currency: currency,
date: date,
name: title,
source: "traderepublic",
notes: subtitle
)
end
Rails.logger.info "TraderepublicAccount::Processor - Imported: #{title} (#{subtitle}) - #{amount} #{currency}"
rescue => e
Rails.logger.error "TraderepublicAccount::Processor - Error processing transaction #{txn['id']}: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
end
def is_trade?(text)
return false unless text
text_lower = text.downcase
# Support multiple languages and variations
# Manual orders:
# French: Ordre d'achat, Ordre de vente, Ordre d'achat sur stop
# English: Buy order, Sell order
# German: Kauforder, Verkaufsorder
# Savings plans (automatic recurring purchases):
# French: Plan d'épargne exécuté
# English: Savings plan executed
# German: Sparplan ausgeführt
text_lower.match?(/ordre d'achat|ordre de vente|buy order|sell order|kauforder|verkaufsorder|plan d'épargne exécuté|savings plan executed|sparplan ausgeführt/)
end
def process_trade(external_id, title, subtitle, amount, currency, date, txn)
# Extraire ISIN depuis l'icon (toujours présent)
isin = extract_isin(txn["icon"])
Rails.logger.info "[TR Processor] process_trade: extracted ISIN=#{isin.inspect} from icon for txn id=#{external_id}"
# 1. Chercher dans trade_details (détail transaction)
trade_details = txn["trade_details"] || {}
quantity_str = nil
price_str = nil
isin_str = nil
# Extraction robuste depuis trade_details['sections'] (niveau 1 et imbriqué)
if trade_details.is_a?(Hash) && trade_details["sections"].is_a?(Array)
trade_details["sections"].each do |section|
# Cas direct (niveau 1, Transaction)
if section["type"] == "table" && section["title"] == "Transaction" && section["data"].is_a?(Array)
section["data"].each do |row|
case row["title"]
when "Titres", "Actions"
quantity_str ||= row.dig("detail", "text")
when "Cours du titre", "Prix du titre"
price_str ||= row.dig("detail", "text")
end
end
end
# Cas direct (niveau 1, tout table)
if section["type"] == "table" && section["data"].is_a?(Array)
section["data"].each do |row|
case row["title"]
when "Actions"
quantity_str ||= row.dig("detail", "text")
when "Prix du titre"
price_str ||= row.dig("detail", "text")
end
# Cas imbriqué : row["title"] == "Transaction" && row["detail"]["action"]["payload"]["sections"]
if row["title"] == "Transaction" && row.dig("detail", "action", "payload", "sections").is_a?(Array)
row["detail"]["action"]["payload"]["sections"].each do |sub_section|
next unless sub_section["type"] == "table" && sub_section["data"].is_a?(Array)
sub_section["data"].each do |sub_row|
case sub_row["title"]
when "Actions", "Titres"
quantity_str ||= sub_row.dig("detail", "text")
when "Prix du titre", "Cours du titre"
price_str ||= sub_row.dig("detail", "text")
end
end
end
end
end
end
end
end
# Fallback : champs directs
quantity_str ||= txn["quantity"] || txn["qty"]
price_str ||= txn["price"] || txn["price_per_unit"]
# ISIN : on garde la logique précédente
isin_str = nil
if trade_details.is_a?(Hash) && trade_details["sections"].is_a?(Array)
trade_details["sections"].each do |section|
if section["data"].is_a?(Hash) && section["data"]["icon"]
possible_isin = extract_isin(section["data"]["icon"])
isin_str ||= possible_isin if possible_isin
end
end
end
isin = isin_str if isin_str.present?
Rails.logger.info "TradeRepublic: Processing trade #{title}"
Rails.logger.info "TradeRepublic: Values - Qty: #{quantity_str}, Price: #{price_str}, ISIN: #{isin_str || isin}"
Rails.logger.info "[TR Processor] process_trade: after details, ISIN=#{isin.inspect}, quantity_str=#{quantity_str.inspect}, price_str=#{price_str.inspect}"
# Correction : s'assurer que le subtitle utilisé est bien celui du trade (issu de txn["subtitle"] si besoin)
effective_subtitle = subtitle.presence || txn["subtitle"]
# Détermine le type d'opération (buy/sell)
op_type = nil
if effective_subtitle.to_s.downcase.match?(/sell|vente|verkauf/)
op_type = "sell"
elsif effective_subtitle.to_s.downcase.match?(/buy|achat|kauf/)
op_type = "buy"
end
quantity = parse_quantity(quantity_str) if quantity_str
quantity = -quantity if quantity && op_type == "sell"
price = parse_price(price_str) if price_str
# Extract ticker and mic from instrument_details if available
instrument_data = txn["instrument_details"]
ticker = nil
mic = nil
if instrument_data.present?
ticker_mic_pairs = extract_ticker_and_mic(instrument_data, isin)
if ticker_mic_pairs.any?
ticker, mic = ticker_mic_pairs.first
end
end
# Si on n'a pas de quantité ou de prix, fallback transaction simple
if isin && quantity.nil? && amount && amount != 0
Rails.logger.warn "TradeRepublic: Cannot extract quantity/price for trade #{external_id} (#{title})"
Rails.logger.warn "TradeRepublic: Importing as transaction instead of trade"
Rails.logger.info "[TR Processor] process_trade: skipping trade creation for txn id=#{external_id} (missing quantity or price)"
import_adapter.import_transaction(
external_id: external_id,
amount: amount,
currency: currency,
date: date,
name: title,
source: "traderepublic",
notes: subtitle
)
return
end
# Créer le trade si toutes les infos sont là
if isin && quantity && price
Rails.logger.info "[TR Processor] process_trade: ready to call find_or_create_security for ISIN=#{isin.inspect}, title=#{title.inspect}, ticker=#{ticker.inspect}, mic=#{mic.inspect}"
security = find_or_create_security(isin, title, ticker, mic)
if security
Rails.logger.info "[TR Processor] process_trade: got security id=#{security.id} for ISIN=#{isin}"
Rails.logger.info "[TR Processor] TRADE IMPORT: external_id=#{external_id} qty=#{quantity} security_id=#{security.id} isin=#{isin} ticker=#{ticker} mic=#{mic} op_type=#{op_type}"
import_adapter.import_trade(
external_id: external_id,
security: security,
quantity: quantity,
price: price,
amount: amount,
currency: currency,
date: date,
name: "#{title} - #{subtitle}",
source: "traderepublic",
trade_type: op_type
)
return
else
Rails.logger.error "[TR Processor] process_trade: find_or_create_security returned nil for ISIN=#{isin}"
Rails.logger.error "TradeRepublic: Could not create security for ISIN #{isin}"
end
end
# Fallback : transaction simple
Rails.logger.warn "TradeRepublic: Falling back to transaction for #{external_id}: ISIN=#{isin}, Qty=#{quantity}, Price=#{price}"
Rails.logger.info "[TR Processor] process_trade: fallback to cash transaction for txn id=#{external_id}"
import_adapter.import_transaction(
external_id: external_id,
amount: amount,
currency: currency,
date: date,
name: title,
source: "traderepublic",
notes: subtitle
)
end
def extract_all_data(obj, result = {})
case obj
when Hash
# Check if this hash looks like a data item with title/detail
if obj["title"] && obj["detail"] && obj["detail"].is_a?(Hash) && obj["detail"]["text"]
result[obj["title"]] = obj["detail"]["text"]
end
# Recursively process all values
obj.each do |key, value|
extract_all_data(value, result)
end
when Array
obj.each do |item|
extract_all_data(item, result)
end
end
result
end
def parse_quantity(quantity_str)
# quantity_str format: "3 Shares" or "0.01 BTC"
return nil unless quantity_str
token = quantity_str.to_s.split.first
cleaned = token.to_s.gsub(/[^0-9.,\-+]/, "")
return nil if cleaned.blank?
begin
Float(cleaned.tr(",", ".")).abs
rescue ArgumentError, TypeError
nil
end
end
def parse_price(price_str)
# price_str format: "€166.70" or "$500.00" - extract numeric substring and parse strictly
return nil unless price_str
match = price_str.to_s.match(/[+\-]?\d+(?:[.,]\d+)*/)
return nil unless match
cleaned = match[0].tr(",", ".")
begin
Float(cleaned)
rescue ArgumentError, TypeError
nil
end
end
def extract_isin(isin_or_icon)
return nil unless isin_or_icon
# If it's already an ISIN (12 characters)
return isin_or_icon if isin_or_icon.match?(/^[A-Z]{2}[A-Z0-9]{9}\d$/)
# Extract from icon path: "logos/US0378331005/v2"
match = isin_or_icon.match(%r{logos/([A-Z]{2}[A-Z0-9]{9}\d)/})
match ? match[1] : nil
end
def find_or_create_security(isin, fallback_name = nil, ticker = nil, mic = nil)
# Always use string and upcase safely
safe_isin = isin.to_s.upcase
safe_ticker = ticker.to_s.upcase if ticker
safe_mic = mic.to_s.upcase if mic
resolved = TradeRepublic::SecurityResolver.new(safe_isin, name: fallback_name, ticker: safe_ticker, mic: safe_mic).resolve
return resolved if resolved
Rails.logger.error "TradeRepublic: SecurityResolver n'a pas pu trouver ou créer de security pour ISIN=#{safe_isin}, name=#{fallback_name}, ticker=#{safe_ticker}, mic=#{safe_mic}"
nil
end
# fetch_trade_details et fetch_instrument_details supprimés : tout est lu depuis raw_transactions_payload
def extract_security_name(instrument_data)
return nil unless instrument_data.is_a?(Hash)
# Trade Republic returns instrument details with the name in different possible locations:
# 1. Direct name field
# 2. First exchange's nameAtExchange (most common for stocks/ETFs)
# 3. shortName or typeNameAtExchange for other instruments
# Try direct name fields first
name = instrument_data["name"] ||
instrument_data["shortName"] ||
instrument_data["typeNameAtExchange"]
# If no direct name, try getting from first active exchange
if name.blank? && instrument_data["exchanges"].is_a?(Array)
active_exchange = instrument_data["exchanges"].find { |ex| ex["active"] == true }
exchange = active_exchange || instrument_data["exchanges"].first
name = exchange["nameAtExchange"] if exchange
end
name&.strip
end
# Returns an Array of [ticker, mic] pairs ordered by relevance (active exchanges first)
def extract_ticker_and_mic(instrument_data, isin)
return [[isin, nil]] unless instrument_data.is_a?(Hash)
exchanges = instrument_data["exchanges"]
return [[isin, nil]] unless exchanges.is_a?(Array) && exchanges.any?
# Order exchanges by active first, then the rest in their provided order
ordered = exchanges.partition { |ex| ex["active"] == true }.flatten
pairs = ordered.map do |ex|
ticker = ex["symbolAtExchange"] || ex["symbol"]
mic = ex["slug"] || ex["mic"] || ex["mic_code"]
ticker = isin if ticker.blank?
ticker = clean_ticker(ticker)
[ticker, mic]
end
# Remove duplicates while preserving order
pairs.map { |t, m| [t, m] }.uniq
end
def clean_ticker(ticker)
return ticker unless ticker
# Remove common suffixes
# Examples: "AAPL.US" -> "AAPL", "BTCEUR.SPOT" -> "BTC/EUR" (keep as is for crypto)
cleaned = ticker.strip
# Don't clean if it looks like a crypto pair (contains /)
return cleaned if cleaned.include?("/")
# Remove .SPOT, .US, etc.
cleaned = cleaned.split(".").first if cleaned.include?(".")
cleaned
end
def process_holdings(account)
payload = traderepublic_account.raw_payload
return unless payload.is_a?(Hash)
# The payload is wrapped in a 'raw' key by the Importer
portfolio_data = payload["raw"] || payload
positions = extract_positions(portfolio_data)
if positions.empty?
Rails.logger.info "TraderepublicAccount::Processor - No positions found in payload."
Rails.logger.info "TraderepublicAccount::Processor - Calculating holdings from trades..."
# Calculate holdings from trades using ForwardCalculator
begin
calculated_holdings = Holding::ForwardCalculator.new(account).calculate
# Importer tous les holdings calculés, y compris qty = 0 (pour refléter la fermeture de position)
if calculated_holdings.any?
Holding.import!(calculated_holdings, on_duplicate_key_update: {
conflict_target: [ :account_id, :security_id, :date, :currency ],
columns: [ :qty, :price, :amount, :updated_at ]
})
Rails.logger.info "TraderepublicAccount::Processor - Saved #{calculated_holdings.size} calculated holdings (no filter)"
else
Rails.logger.info "TraderepublicAccount::Processor - No holdings calculated from trades"
end
rescue => e
Rails.logger.error "TraderepublicAccount::Processor - Error calculating holdings from trades: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
end
return
end
Rails.logger.info "TraderepublicAccount::Processor - Processing #{positions.size} holdings"
positions.each do |pos|
process_single_holding(account, pos)
end
end
def extract_positions(portfolio_data)
return [] unless portfolio_data.is_a?(Hash)
# Try to find categories in different places
# Sometimes the payload is directly the array of categories? No, usually it's an object.
# But sometimes it's nested in 'payload'
categories = []
if portfolio_data["categories"].is_a?(Array)
categories = portfolio_data["categories"]
elsif portfolio_data.dig("payload", "categories").is_a?(Array)
categories = portfolio_data.dig("payload", "categories")
elsif portfolio_data["payload"].is_a?(Hash) && portfolio_data["payload"]["categories"].is_a?(Array)
categories = portfolio_data["payload"]["categories"]
end
Rails.logger.info "TraderepublicAccount::Processor - Categories type: #{categories.class}"
if categories.is_a?(Array)
Rails.logger.info "TraderepublicAccount::Processor - Categories count: #{categories.size}"
if categories.empty?
Rails.logger.info "TraderepublicAccount::Processor - Portfolio data keys: #{portfolio_data.keys}"
Rails.logger.info "TraderepublicAccount::Processor - Payload keys: #{portfolio_data['payload'].keys}" if portfolio_data['payload'].is_a?(Hash)
end
categories.each_with_index do |cat, idx|
Rails.logger.info "TraderepublicAccount::Processor - Category #{idx} keys: #{cat.keys rescue 'not a hash'}"
if cat.is_a?(Hash) && cat["positions"]
Rails.logger.info "TraderepublicAccount::Processor - Category #{idx} positions type: #{cat['positions'].class}"
end
end
end
positions = []
categories.each do |category|
next unless category["positions"].is_a?(Array)
category["positions"].each { |p| positions << p }
end
positions
end
def process_single_holding(account, pos)
isin = pos["isin"]
name = pos["name"]
quantity = pos["netSize"].to_f
# Try to find current value
# Trade Republic usually sends 'netValue' for the total current value of the position
amount = pos["netValue"]&.to_f
# Cost basis
avg_buy_in = pos["averageBuyIn"]&.to_f
cost_basis = avg_buy_in ? (quantity * avg_buy_in) : nil
return unless isin && quantity
if amount.nil?
Rails.logger.warn "TraderepublicAccount::Processor - Holding #{isin} missing netValue. Keys: #{pos.keys}"
return
end
security = find_or_create_security(isin, name)
return unless security
price = quantity.zero? ? 0 : (amount / quantity)
# Prefer position currency if present, else fall back to linked account currency or account default, then final fallback to EUR
currency = pos["currency"] || traderepublic_account.linked_account&.currency || traderepublic_account.linked_account&.default_currency || "EUR"
import_adapter.import_holding(
security: security,
quantity: quantity,
amount: amount,
currency: currency,
date: Date.today,
price: price,
cost_basis: cost_basis,
source: "traderepublic",
external_id: isin,
account_provider_id: traderepublic_account.account_provider&.id
)
rescue => e
Rails.logger.error "TraderepublicAccount::Processor - Error processing holding #{pos['isin']}: #{e.message}"
end
def update_balance(account)
balance = traderepublic_account.current_balance
return unless balance
Rails.logger.info "TraderepublicAccount::Processor - Updating balance to #{balance}"
# Update account balance
account.update(balance: balance)
end
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(traderepublic_account.linked_account)
end
end

View File

@@ -0,0 +1,9 @@
# Custom error class for Trade Republic
class TraderepublicError < StandardError
attr_reader :error_code
def initialize(message, error_code = :unknown_error)
super(message)
@error_code = error_code
end
end

View File

@@ -0,0 +1,237 @@
class TraderepublicItem < ApplicationRecord
include Syncable, Provided
enum :status, { good: "good", requires_update: "requires_update" }, default: :good, prefix: true
# Helper to detect if ActiveRecord Encryption is configured for this app
def self.encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
# Encrypt sensitive credentials if ActiveRecord encryption is configured
if encryption_ready?
encrypts :phone_number, deterministic: true
encrypts :pin, deterministic: true
encrypts :session_token # non-deterministic (default)
encrypts :refresh_token # non-deterministic (default)
end
validates :name, presence: true
validates :phone_number, presence: true, on: :create
validates :phone_number, format: { with: /\A\+\d{10,15}\z/, message: "must be in international format (e.g., +491234567890)" }, on: :create, if: :phone_number_changed?
validates :pin, presence: { message: I18n.t("traderepublic_items.errors.pin_required", default: "PIN is required") }, on: :create
belongs_to :family
has_one_attached :logo
has_many :traderepublic_accounts, dependent: :destroy
has_many :accounts, through: :traderepublic_accounts
scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
end
def import_latest_traderepublic_data(skip_token_refresh: false, sync: nil)
provider = traderepublic_provider
unless provider
Rails.logger.error "TraderepublicItem #{id} - Cannot import: TradeRepublic provider is not configured (missing credentials)"
raise StandardError.new(I18n.t("traderepublic_items.errors.provider_not_configured", default: "TradeRepublic provider is not configured"))
end
# Try import with current tokens
TraderepublicItem::Importer.new(self, traderepublic_provider: provider).import
rescue TraderepublicError => e
# If authentication failed and we have credentials, try re-authenticating automatically
if [:unauthorized, :auth_failed].include?(e.error_code) && !skip_token_refresh && credentials_configured?
Rails.logger.warn "TraderepublicItem #{id} - Authentication failed, attempting automatic re-authentication"
if auto_reauthenticate
Rails.logger.info "TraderepublicItem #{id} - Re-authentication successful, retrying import"
# Retry import with fresh tokens (skip_token_refresh to avoid infinite loop)
return import_latest_traderepublic_data(skip_token_refresh: true)
else
Rails.logger.error "TraderepublicItem #{id} - Automatic re-authentication failed"
update!(status: :requires_update)
raise StandardError.new("Session expired and automatic re-authentication failed. Please log in again manually.")
end
else
Rails.logger.error "TraderepublicItem #{id} - Failed to import data: #{e.message}"
raise
end
rescue => e
Rails.logger.error "TraderepublicItem #{id} - Failed to import data: #{e.message}"
raise
end
def credentials_configured?
phone_number.present? && pin.present?
end
def session_configured?
session_token.present?
end
def traderepublic_provider
return nil unless credentials_configured?
@traderepublic_provider ||= Provider::Traderepublic.new(
phone_number: phone_number,
pin: pin,
session_token: session_token,
refresh_token: refresh_token,
raw_cookies: session_cookies
)
end
# Initiate login and store processId
def initiate_login!
provider = Provider::Traderepublic.new(
phone_number: phone_number,
pin: pin
)
result = provider.initiate_login
update!(
process_id: result["processId"],
session_cookies: { jsessionid: provider.jsessionid }.compact
)
result
end
# Complete login with device PIN
def complete_login!(device_pin)
raise I18n.t("traderepublic_items.errors.no_process_id", default: "No processId found") unless process_id
provider = Provider::Traderepublic.new(
phone_number: phone_number,
pin: pin
)
provider.process_id = process_id
provider.jsessionid = session_cookies&.dig("jsessionid") if session_cookies.is_a?(Hash)
provider.verify_device_pin(device_pin)
# Save session data
update!(
session_token: provider.session_token,
refresh_token: provider.refresh_token,
session_cookies: provider.raw_cookies,
process_id: nil, # Clear processId after successful login
status: :good
)
true
rescue => e
Rails.logger.error "TraderepublicItem #{id}: Login failed - #{e.message}"
update!(status: :requires_update)
false
end
# Check if login needs to be completed
def pending_login?
process_id.present? && session_token.blank?
end
# Automatic re-authentication when tokens expire
# Trade Republic doesn't support token refresh, so we need to re-authenticate from scratch
def auto_reauthenticate
Rails.logger.info "TraderepublicItem #{id}: Starting automatic re-authentication"
unless credentials_configured?
Rails.logger.error "TraderepublicItem #{id}: Cannot auto re-authenticate - credentials not configured"
return false
end
begin
# Step 1: Initiate login to get processId
result = initiate_login!
Rails.logger.info "TraderepublicItem #{id}: Login initiated, processId: #{process_id}"
# Trade Republic requires SMS verification - we can't auto-complete this step
# Mark as requires_update so user knows they need to re-authenticate
Rails.logger.warn "TraderepublicItem #{id}: SMS verification required - automatic re-authentication cannot proceed"
update!(status: :requires_update)
false
rescue => e
Rails.logger.error "TraderepublicItem #{id}: Automatic re-authentication failed - #{e.message}"
false
end
end
def syncer
@syncer ||= TraderepublicItem::Syncer.new(self)
end
def process_accounts
# Process each account's transactions and create entries
traderepublic_accounts.includes(:linked_account).each do |tr_account|
next unless tr_account.linked_account
TraderepublicAccount::Processor.new(tr_account).process
end
end
def schedule_account_syncs(parent_sync:, window_start_date: nil, window_end_date: nil)
# Trigger balance calculations for linked accounts
traderepublic_accounts.joins(:account).merge(Account.visible).each do |tr_account|
tr_account.linked_account.sync_later(
parent_sync: parent_sync,
window_start_date: window_start_date,
window_end_date: window_end_date
)
end
end
# Enqueue a sync for this item
def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil)
sync = Sync.create!(
syncable: self,
parent: parent_sync,
window_start_date: window_start_date,
window_end_date: window_end_date
)
TraderepublicItem::SyncJob.perform_later(sync)
sync
end
# Perform sync using the Sync pattern
def perform_sync(sync)
sync.start! if sync.may_start?
begin
provider = traderepublic_provider
unless provider
sync.fail!
sync.update(error: I18n.t("traderepublic_items.errors.provider_not_configured", default: "TradeRepublic provider is not configured"))
return false
end
importer = TraderepublicItem::Importer.new(self, traderepublic_provider: provider)
success = importer.import
if success
sync.complete!
return true
else
sync.fail!
sync.update(error: "Import failed")
return false
end
rescue => e
sync.fail!
sync.update(error: e.message)
Rails.logger.error "TraderepublicItem #{id} - perform_sync failed: #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
return false
end
end
end

View File

@@ -0,0 +1,296 @@
class TraderepublicItem::Importer
include TraderepublicSessionConfigurable
attr_reader :traderepublic_item, :provider
# Utility to find or create a security by ISIN, otherwise by ticker/MIC
def find_or_create_security_from_tr(position_or_txn)
isin = position_or_txn["isin"]&.strip&.upcase.presence
ticker = position_or_txn["ticker"]&.strip.presence || position_or_txn["symbol"]&.strip.presence
mic = position_or_txn["exchange_operating_mic"]&.strip.presence || position_or_txn["mic"]&.strip.presence
name = position_or_txn["name"]&.strip.presence
TradeRepublic::SecurityResolver.new(isin, name: name, ticker: ticker, mic: mic).resolve
end
def initialize(traderepublic_item, traderepublic_provider: nil)
@traderepublic_item = traderepublic_item
@provider = traderepublic_provider || traderepublic_item.traderepublic_provider
end
def import
raise "Provider not configured" unless provider
ensure_session_configured!
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Starting import"
# Import portfolio and create/update accounts
import_portfolio
# Import timeline transactions
import_transactions
# Mark sync as successful
traderepublic_item.update!(status: :good)
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Import completed successfully"
true
rescue TraderepublicError => e
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Import failed - #{e.message}"
# Mark as requires_update if authentication error
if [ :unauthorized, :auth_failed ].include?(e.error_code)
traderepublic_item.update!(status: :requires_update)
raise e # Re-raise so the caller can handle re-auth
end
false
rescue => e
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Import failed - #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
false
end
private
def import_portfolio
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching portfolio data"
portfolio_data = provider.get_portfolio
cash_data = provider.get_cash
parsed_portfolio = if portfolio_data
portfolio_data.is_a?(String) ? JSON.parse(portfolio_data) : portfolio_data
else
{}
end
parsed_cash = if cash_data
cash_data.is_a?(String) ? JSON.parse(cash_data) : cash_data
else
nil
end
# Get or create main account
account = find_or_create_main_account(parsed_portfolio)
# Update account with portfolio data
update_account_with_portfolio(account, parsed_portfolio, parsed_cash)
# Import holdings/positions
import_holdings(account, parsed_portfolio)
rescue JSON::ParserError => e
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse portfolio data - #{e.message}"
end
def import_transactions
begin
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching transactions"
# Find main account
account = traderepublic_item.traderepublic_accounts.first
return unless account
# Get the date of the last synced transaction for incremental sync
since_date = account.last_transaction_date
# Force a full sync if no transaction actually exists
if account.linked_account.nil? || !account.linked_account.transactions.exists?
since_date = nil
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Forcing initial full sync (no transactions exist)"
elsif since_date
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Incremental sync from #{since_date}"
else
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Initial full sync"
end
transactions_data = provider.get_timeline_transactions(since: since_date)
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data class=#{transactions_data.class} keys=#{transactions_data.respond_to?(:keys) ? transactions_data.keys : 'n/a'}"
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data preview=#{transactions_data.inspect[0..300]}"
return unless transactions_data
parsed = transactions_data.is_a?(String) ? JSON.parse(transactions_data) : transactions_data
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: parsed class=#{parsed.class} keys=#{parsed.respond_to?(:keys) ? parsed.keys : 'n/a'}"
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: parsed preview=#{parsed.inspect[0..300]}"
# Add instrument details for each transaction (if ISIN present)
items = if parsed.is_a?(Hash)
parsed["items"]
elsif parsed.is_a?(Array)
pair = parsed.find { |p| p[0] == "items" }
pair ? pair[1] : nil
end
if items.is_a?(Array)
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: items count before enrichment = #{items.size}"
items.each do |txn|
# Enrich with instrument_details (ISIN) if possible
isin = txn["isin"]
isin ||= txn.dig("instrument", "isin")
isin ||= extract_isin_from_icon(txn["icon"])
if isin.present? && isin.match?(/^[A-Z]{2}[A-Z0-9]{10}$/)
begin
instrument_details = provider.get_instrument_details(isin)
txn["instrument_details"] = instrument_details if instrument_details.present?
rescue => e
Rails.logger.warn "TraderepublicItem #{traderepublic_item.id}: Failed to fetch instrument details for ISIN #{isin} - #{e.message}"
end
end
# Enrich with trade_details (timelineDetailV2) for each transaction
begin
trade_details = provider.get_timeline_detail(txn["id"])
txn["trade_details"] = trade_details if trade_details.present?
rescue => e
Rails.logger.warn "TraderepublicItem #{traderepublic_item.id}: Failed to fetch trade details for txn #{txn["id"]} - #{e.message}"
end
end
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: items count after enrichment = #{items.size}"
end
# Detailed log before saving the snapshot
items_count = items.is_a?(Array) ? items.size : 0
preview = items.is_a?(Array) && items_count > 0 ? items.first(2).map { |i| i.slice('id', 'title', 'isin') } : items.inspect
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Transactions snapshot contains #{items_count} items (with instrument details). Preview: #{preview}"
# Update account with transactions data
account.upsert_traderepublic_transactions_snapshot!(parsed)
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Snapshot saved with #{items_count} items."
# Process transactions
process_transactions(account, parsed)
rescue JSON::ParserError => e
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse transactions - #{e.message}"
rescue => e
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Unexpected error in import_transactions - #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if e.respond_to?(:backtrace)
raise
end
def find_or_create_main_account(portfolio_data)
# TradeRepublic typically has one main account
account = traderepublic_item.traderepublic_accounts.first_or_initialize(
account_id: "main",
name: "Trade Republic",
currency: "EUR"
)
account.save! if account.new_record?
account
end
def update_account_with_portfolio(account, portfolio_data, cash_data = nil)
# Extract cash/balance from portfolio if available
cash_value = extract_cash_value(portfolio_data, cash_data)
account.upsert_traderepublic_snapshot!({
id: "main",
name: "Trade Republic",
currency: "EUR",
balance: cash_value,
status: "active",
type: "investment",
raw: portfolio_data
})
end
def extract_cash_value(portfolio_data, cash_data = nil)
# Try to extract cash value from cash_data first
if cash_data.is_a?(Array) && cash_data.first.is_a?(Hash)
# [{"accountNumber"=>"...", "currencyId"=>"EUR", "amount"=>1064.3}]
return cash_data.first["amount"]
end
# Try to extract cash value from portfolio structure
# This depends on the actual API response structure
return 0 unless portfolio_data.is_a?(Hash)
# Common patterns in trading APIs
portfolio_data.dig("cash", "value") ||
portfolio_data.dig("availableCash") ||
portfolio_data.dig("balance") ||
0
end
def import_holdings(account, portfolio_data)
positions = extract_positions(portfolio_data)
return if positions.empty?
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Processing #{positions.size} positions"
linked_account = account.linked_account
return unless linked_account
positions.each do |position|
security = find_or_create_security_from_tr(position)
holding_date = position["date"] || Date.current # fallback to today if nil
next unless holding_date.present?
holding = Holding.find_or_initialize_by(
account: linked_account,
security: security,
date: holding_date,
currency: position["currency"]
)
holding.qty = position["quantity"]
holding.price = position["price"]
holding.save!
end
end
def extract_positions(portfolio_data)
return [] unless portfolio_data.is_a?(Hash)
# Extract positions based on the Portfolio interface structure
categories = portfolio_data["categories"] || []
positions = []
categories.each do |category|
next unless category["positions"].is_a?(Array)
category["positions"].each do |position|
positions << position
end
end
positions
end
def extract_isin_from_icon(icon)
return nil unless icon.is_a?(String)
match = icon.match(%r{logos/([A-Z]{2}[A-Z0-9]{9}\d)/})
match ? match[1] : nil
end
def process_transactions(account, transactions_data)
return unless transactions_data.is_a?(Array)
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Processing #{transactions_data.size} transactions"
linked_account = account.linked_account
return unless linked_account
trades = []
transactions_data.each do |txn|
security = find_or_create_security_from_tr(txn)
trade = Trade.create!(
account: linked_account,
security: security,
qty: txn["quantity"],
price: txn["price"],
date: txn["date"],
currency: txn["currency"]
)
if block_given?
yield trade
else
trades << trade
end
end
trades unless block_given?
end
end

View File

@@ -0,0 +1,12 @@
module TraderepublicItem::Provided
extend ActiveSupport::Concern
def traderepublic_provider
return nil unless credentials_configured?
Provider::Traderepublic.new(
phone_number: phone_number,
pin: pin
)
end
end

View File

@@ -0,0 +1,10 @@
class TraderepublicItem::SyncCompleteEvent
def initialize(traderepublic_item)
@traderepublic_item = traderepublic_item
end
def broadcast
# Placeholder - add any post-sync broadcasts here if needed
Rails.logger.info "TraderepublicItem::SyncCompleteEvent - Sync completed for item #{@traderepublic_item.id}"
end
end

View File

@@ -0,0 +1,91 @@
class TraderepublicItem::Syncer
attr_reader :traderepublic_item
def initialize(traderepublic_item)
@traderepublic_item = traderepublic_item
end
def perform_sync(sync)
# Phase 1: Check session status
unless traderepublic_item.session_configured?
Rails.logger.error "TraderepublicItem::Syncer - No session configured for item #{traderepublic_item.id}"
traderepublic_item.update!(status: :requires_update)
sync.update!(status_text: "Login required") if sync.respond_to?(:status_text)
return
end
# Phase 2: Import data from TradeRepublic API
sync.update!(status_text: "Importing portfolio from Trade Republic...") if sync.respond_to?(:status_text)
begin
traderepublic_item.import_latest_traderepublic_data(sync: sync)
rescue TraderepublicError => e
Rails.logger.error "TraderepublicItem::Syncer - Import failed: #{e.message}"
# Mark as requires_update if authentication error
if [ :unauthorized, :auth_failed ].include?(e.error_code)
traderepublic_item.update!(status: :requires_update)
sync.update!(status_text: "Authentication failed - login required") if sync.respond_to?(:status_text)
else
sync.update!(status_text: "Import failed: #{e.message}") if sync.respond_to?(:status_text)
end
return
end
# Phase 3: Check account setup status and collect sync statistics
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
total_accounts = traderepublic_item.traderepublic_accounts.count
linked_accounts = traderepublic_item.traderepublic_accounts.joins(:linked_account).merge(Account.visible)
unlinked_accounts = traderepublic_item.traderepublic_accounts.includes(:linked_account).where(accounts: { id: nil })
# Store sync statistics for display
sync_stats = {
total_accounts: total_accounts,
linked_accounts: linked_accounts.count,
unlinked_accounts: unlinked_accounts.count
}
# Set pending_account_setup if there are unlinked accounts
if unlinked_accounts.any?
traderepublic_item.update!(pending_account_setup: true)
sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text)
else
traderepublic_item.update!(pending_account_setup: false)
end
# Phase 4: Process transactions for linked accounts only
if linked_accounts.any?
sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text)
Rails.logger.info "TraderepublicItem::Syncer - Processing #{linked_accounts.count} linked accounts (appel Processor sur chaque compte)"
traderepublic_item.process_accounts
Rails.logger.info "TraderepublicItem::Syncer - Finished processing accounts"
# Phase 5: Schedule balance calculations for linked accounts
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
traderepublic_item.schedule_account_syncs(
parent_sync: sync,
window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date
)
else
Rails.logger.info "TraderepublicItem::Syncer - No linked accounts to process (Importer utilisé uniquement à l'import initial)"
end
# Store sync statistics in the sync record for status display
if sync.respond_to?(:sync_stats)
sync.update!(sync_stats: sync_stats)
end
# Recalculate holdings for all linked accounts
linked_accounts.each do |traderepublic_account|
account = traderepublic_account.linked_account
next unless account
Rails.logger.info "TraderepublicItem::Syncer - Recalculating holdings for account #{account.id}"
Holding::Materializer.new(account, strategy: :forward).materialize_holdings
end
end
def perform_post_sync
# no-op for now
end
end

View File

@@ -0,0 +1,102 @@
<div class="space-y-6">
<div class="text-sm text-secondary space-y-2">
<p><strong><%= t("settings.providers.traderepublic_panel.important", default: "Important:") %></strong> <%= t("settings.providers.traderepublic_panel.intro", default: "Trade Republic integration uses an unofficial API and requires 2-factor authentication.") %></p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li><%= t("settings.providers.traderepublic_panel.need_phone_pin", default: "You'll need your phone number and Trade Republic PIN") %></li>
<li><%= t("settings.providers.traderepublic_panel.verification_code", default: "A verification code will be sent to your Trade Republic app during setup") %></li>
<li><%= t("settings.providers.traderepublic_panel.sessions_stored", default: "Sessions are stored securely and will need to be refreshed periodically") %></li>
</ul>
<div class="p-3 bg-warning/10 border border-warning rounded-lg mt-3">
<p class="text-xs text-warning">
<%= icon "alert-triangle", class: "inline-block w-4 h-4 mr-1" %>
<strong><%= t("settings.providers.traderepublic_panel.note", default: "Note:") %></strong> <%= t("settings.providers.traderepublic_panel.reverse_engineered", default: "This integration is based on reverse-engineered API endpoints and may break without notice. Use at your own risk.") %>
</p>
</div>
</div>
<% items = Current.family.traderepublic_items.where.not(phone_number: nil).order(created_at: :desc) %>
<% if items.any? %>
<div class="space-y-3">
<h3 class="text-sm font-medium text-primary"><%= t("settings.providers.traderepublic_panel.connected_accounts", default: "Connected Accounts") %></h3>
<% items.each do |item| %>
<div id="traderepublic-item-<%= item.id %>" class="p-4 border border-primary rounded-lg <%= item.status == "requires_update" ? "bg-warning/5 border-warning" : "" %>">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-sm text-primary"><%= item.name %></span>
<% if item.pending_login? %>
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-warning/10 text-warning border border-warning">
<%= t("settings.providers.traderepublic_panel.pending_verification", default: "Pending Verification") %>
</span>
<% elsif item.status == "requires_update" %>
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-error/10 text-error border border-error">
<%= t("settings.providers.traderepublic_panel.login_required", default: "Login Required") %>
</span>
<% elsif item.session_configured? %>
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-success/10 text-success border border-success">
<%= t("settings.providers.traderepublic_panel.connected", default: "Connected") %>
</span>
<% end %>
</div>
<div class="text-xs text-secondary mt-1">
<% if item.phone_number %>
<%= item.phone_number.gsub(/\d(?=\d{4})/, '*') %> •
<% end %>
<%= item.traderepublic_accounts.count %> <%= t("settings.providers.traderepublic_panel.accounts", default: "account(s)") %>
<% if item.last_synced_at %>
• <%= t("settings.providers.traderepublic_panel.last_synced", default: "Last synced") %> <%= time_ago_in_words(item.last_synced_at) %> <%= t("settings.providers.traderepublic_panel.ago", default: "ago") %>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<% if item.pending_login? %>
<%= link_to verify_pin_traderepublic_item_path(item),
class: "inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover",
data: { turbo_frame: "modal" } do %>
<%= icon "shield-check", class: "w-3 h-3" %>
<span><%= t("settings.providers.traderepublic_panel.verify_pin", default: "Verify PIN") %></span>
<% end %>
<% else %>
<%= button_to manual_sync_traderepublic_item_path(item),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "modal" } do %>
<%= icon "refresh-cw", class: "w-3 h-3" %>
<span><%= t("settings.providers.traderepublic_panel.manual_sync", default: "Manual sync") %></span>
<% end %>
<% end %>
<%= button_to traderepublic_item_path(item),
method: :delete,
form: {
data: {
turbo_confirm: t("settings.providers.traderepublic_panel.disconnect_confirm", default: "Are you sure you want to disconnect this Trade Republic account?") ,
turbo_frame: "_top"
}
},
class: "inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg text-error button-bg-secondary hover:button-bg-secondary-hover" do %>
<%= icon "trash-2", class: "w-3 h-3" %>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="p-4 bg-subtle border border-primary rounded-lg">
<p class="text-sm text-secondary">
<%= t("settings.providers.traderepublic_panel.no_accounts", default: "No Trade Republic accounts connected yet. Use the 'Connect Account' button when adding a new account.") %>
</p>
</div>
<% end %>
<div class="text-xs text-secondary">
<p><strong><%= t("settings.providers.traderepublic_panel.need_help", default: "Need help?") %></strong> <%= t("settings.providers.traderepublic_panel.guides_html", default: "Visit the <a href='%{url}' class='link'>guides</a> for detailed setup instructions.", url: settings_guides_path).html_safe %></p>
</div>
</div>

View File

@@ -31,6 +31,12 @@
<%# They require custom UI for connection management, status display, and sync actions. %>
<%# The controller excludes them from @provider_configurations (see prepare_show_context). %>
<%= settings_section title: "Trade Republic", collapsible: true, open: false do %>
<turbo-frame id="traderepublic-providers-panel">
<%= render "settings/providers/traderepublic_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Lunch Flow", collapsible: true, open: false do %>
<turbo-frame id="lunchflow-providers-panel">
<%= render "settings/providers/lunchflow_panel" %>

View File

@@ -0,0 +1,15 @@
<% if flash.any? %>
<div id="flash-messages" class="space-y-2">
<% flash.each do |type, message| %>
<%# Map Rails flash types to Tailwind/Design System classes %>
<% css_class = case type.to_sym
when :notice then 'bg-success text-on-success'
when :alert then 'bg-error text-on-error'
else 'bg-secondary text-on-secondary'
end %>
<div class="rounded px-4 py-2 text-sm font-medium <%= css_class %>">
<%= message %>
</div>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,22 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title", default: "Connection Error")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="p-4 bg-error/10 border border-error rounded-lg">
<p class="text-sm text-error">
<%= icon "alert-circle", class: "inline-block w-5 h-5 mr-2" %>
<%= local_assigns[:error_message] || t(".generic_error", default: "An error occurred while connecting to Trade Republic.") %>
</p>
</div>
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".back", default: "Go Back"), local_assigns[:return_path] || new_account_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover",
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
</div>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,135 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title", default: "Verify Device PIN")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">
<%= icon "check-circle", class: "inline-block w-5 h-5 mr-2" %>
<%= t(".code_sent", default: "Verification code sent to your Trade Republic app!") %>
</p>
</div>
<div class="p-4 bg-accent/10 border border-accent rounded-lg">
<p class="text-sm text-primary">
<%= icon "smartphone", class: "inline-block w-5 h-5 mr-2" %>
<%= t(".instruction", default: "Please enter the 4-digit code from your Trade Republic app below.") %>
</p>
</div>
<div id="pin-form" class="space-y-4">
<div>
<label for="device_pin" class="block text-sm font-medium text-primary mb-2">
<%= t(".pin_label", default: "Verification Code") %>
</label>
<input
type="text"
id="device_pin"
name="device_pin"
placeholder="<%= t(".pin_placeholder", default: "Enter 4-digit code") %>"
maxlength="4"
pattern="[0-9]{4}"
autocomplete="off"
class="w-full px-4 py-3 border border-primary rounded-lg text-center text-2xl font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p id="error-message" class="text-xs text-error mt-2 hidden"></p>
</div>
<div id="loading-spinner" class="hidden flex items-center justify-center gap-2 text-sm text-secondary">
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
<span><%= t(".verifying", default: "Verifying...") %></span>
</div>
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel", default: "Cancel"), traderepublic_items_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<button
type="button"
id="verify-btn"
class="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled"
>
<%= t(".verify", default: "Verify") %>
</button>
</div>
</div>
<% end %>
<% end %>
<script>
(function() {
const pinInput = document.getElementById('device_pin');
const verifyBtn = document.getElementById('verify-btn');
const errorMessage = document.getElementById('error-message');
const loadingSpinner = document.getElementById('loading-spinner');
const pinForm = document.getElementById('pin-form');
// Auto-focus on input
pinInput?.focus();
// Only allow numbers
pinInput?.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/[^0-9]/g, '');
errorMessage.classList.add('hidden');
});
// Submit on Enter
pinInput?.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && pinInput.value.length === 4) {
verifyBtn?.click();
}
});
verifyBtn?.addEventListener('click', async () => {
const devicePin = pinInput.value.trim();
if (devicePin.length !== 4) {
errorMessage.textContent = '<%= t(".pin_invalid", default: "Please enter a 4-digit code") %>';
errorMessage.classList.remove('hidden');
return;
}
// Show loading
verifyBtn.disabled = true;
loadingSpinner.classList.remove('hidden');
errorMessage.classList.add('hidden');
try {
const response = await fetch('<%= complete_login_traderepublic_item_path(traderepublic_item) %>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: JSON.stringify({
device_pin: devicePin,
})
});
const data = await response.json();
if (response.ok && data.success) {
// Success - redirect to next step
if (data.redirect_url) {
window.location.href = data.redirect_url;
} else {
// Default redirect to traderepublic items index
window.location.href = '<%= traderepublic_items_path %>';
}
} else {
// Error
errorMessage.textContent = data.error || '<%= t(".verification_failed", default: "Verification failed. Please try again.") %>';
errorMessage.classList.remove('hidden');
verifyBtn.disabled = false;
loadingSpinner.classList.add('hidden');
}
} catch (error) {
console.error('Verification error:', error);
errorMessage.textContent = '<%= t(".network_error", default: "Network error. Please try again.") %>';
errorMessage.classList.remove('hidden');
verifyBtn.disabled = false;
loadingSpinner.classList.add('hidden');
}
});
})();
</script>

View File

@@ -0,0 +1,38 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title", default: "Edit Trade Republic Connection")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<%= form_with model: @traderepublic_item, url: traderepublic_item_path(@traderepublic_item), method: :patch, class: "space-y-4" do |f| %>
<div class="space-y-4">
<div>
<%= f.label :name, t(".name_label", default: "Connection Name"), class: "block text-sm font-medium text-primary mb-1" %>
<%= f.text_field :name,
placeholder: t(".name_placeholder", default: "Trade Republic"),
class: "w-full px-3 py-2 border border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent" %>
<% if @traderepublic_item.errors[:name].any? %>
<p class="text-xs text-error mt-1"><%= @traderepublic_item.errors[:name].first %></p>
<% end %>
</div>
<div class="p-3 bg-info/10 border border-info rounded-lg">
<p class="text-xs text-info">
<%= icon "info", class: "inline-block w-4 h-4 mr-1" %>
<%= t(".info_notice", default: "To update credentials, please delete this connection and create a new one.") %>
</p>
</div>
</div>
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel", default: "Cancel"), traderepublic_items_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= f.submit t(".save", default: "Save Changes"),
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
</div>
<% end %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,121 @@
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold text-primary"><%= t(".title", default: "Trade Republic Connections") %></h2>
<p class="text-secondary mt-1"><%= t(".description", default: "Manage your Trade Republic broker connections") %></p>
</div>
<%= link_to new_traderepublic_item_path,
class: "btn btn-primary",
data: { turbo_frame: "modal" } do %>
<%= icon "plus", class: "w-4 h-4 mr-2" %>
<%= t(".new_connection", default: "New Connection") %>
<% end %>
</div>
<% if @traderepublic_items.any? %>
<div class="space-y-4">
<% @traderepublic_items.each do |item| %>
<div class="bg-container border border-secondary rounded-lg p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
<h3 class="text-lg font-medium text-primary"><%= item.name %></h3>
<% if item.pending_login? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<%= icon "clock", class: "w-3 h-3 mr-1" %>
Pending Verification
</span>
<% elsif item.status_good? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<%= icon "check-circle", class: "w-3 h-3 mr-1" %>
Connected
</span>
<% elsif item.status_requires_update? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<%= icon "alert-circle", class: "w-3 h-3 mr-1" %>
Requires Update
</span>
<% end %>
</div>
<div class="mt-2 space-y-1 text-sm text-secondary">
<p>
<%= icon "phone", class: "w-4 h-4 inline mr-1" %>
<%= item.phone_number %>
</p>
<% if item.last_synced_at %>
<p>
<%= icon "refresh-cw", class: "w-4 h-4 inline mr-1" %>
Last synced <%= time_ago_in_words(item.last_synced_at) %> ago
</p>
<% end %>
</div>
<% if item.traderepublic_accounts.any? %>
<div class="mt-4">
<p class="text-sm font-medium text-primary mb-2">Linked Accounts:</p>
<div class="flex flex-wrap gap-2">
<% item.traderepublic_accounts.includes(:account).each do |tr_account| %>
<div class="inline-flex items-center px-3 py-1 rounded-md bg-gray-100 text-sm">
<%= icon "briefcase", class: "w-3 h-3 mr-1" %>
<%= tr_account.account&.name || tr_account.name %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<div class="flex items-center gap-2 ml-4">
<% if item.pending_login? %>
<%= link_to verify_pin_traderepublic_item_path(item),
class: "btn btn-sm btn-primary",
data: { turbo_frame: "modal" } do %>
<%= icon "key", class: "w-4 h-4 mr-1" %>
Verify PIN
<% end %>
<% elsif item.status_good? %>
<%= button_to sync_traderepublic_item_path(item),
method: :post,
class: "btn btn-sm btn-secondary",
form: { data: { turbo: false } } do %>
<%= icon "refresh-cw", class: "w-4 h-4 mr-1" %>
Sync Now
<% end %>
<% end %>
<%= link_to edit_traderepublic_item_path(item),
class: "btn btn-sm btn-secondary",
data: { turbo_frame: "modal" } do %>
<%= icon "edit-2", class: "w-4 h-4" %>
<% end %>
<%= button_to traderepublic_item_path(item),
method: :delete,
class: "btn btn-sm btn-secondary text-red-600 hover:text-red-700",
form: { data: { turbo_confirm: "Are you sure you want to delete this connection?" } } do %>
<%= icon "trash-2", class: "w-4 h-4" %>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-12 bg-container border border-secondary rounded-lg">
<%= icon "briefcase", class: "w-12 h-12 mx-auto text-gray-400 mb-4" %>
<h3 class="text-lg font-medium text-primary mb-2">
<%= t(".no_connections", default: "No Trade Republic connections") %>
</h3>
<p class="text-secondary mb-6">
<%= t(".no_connections_description", default: "Connect your Trade Republic account to sync your portfolio and transactions") %>
</p>
<%= link_to new_traderepublic_item_path,
class: "btn btn-primary",
data: { turbo_frame: "modal" } do %>
<%= icon "plus", class: "w-4 h-4 mr-2" %>
<%= t(".add_first_connection", default: "Add Your First Connection") %>
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,97 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title", default: "Connect Trade Republic")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<p class="text-sm text-secondary">
<%= t(".description", default: "Enter your Trade Republic phone number and PIN to connect your account.") %>
</p>
<%= form_with model: @traderepublic_item, url: traderepublic_items_path, method: :post, class: "space-y-4" do |f| %>
<%= hidden_field_tag :accountable_type, @accountable_type %>
<%= hidden_field_tag :return_to, @return_to %>
<div class="space-y-4">
<div>
<%= f.label :name, t(".name_label", default: "Connection Name"), class: "block text-sm font-medium text-primary mb-1" %>
<%= f.text_field :name,
value: "Trade Republic",
placeholder: t(".name_placeholder", default: "Trade Republic"),
class: "w-full px-3 py-2 border border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent" %>
<% if @traderepublic_item.errors[:name].any? %>
<p class="text-xs text-error mt-1"><%= @traderepublic_item.errors[:name].first %></p>
<% end %>
</div>
<div>
<%= f.label :phone_number, t(".phone_label", default: "Phone Number"), class: "block text-sm font-medium text-primary mb-1" %>
<%= f.text_field :phone_number,
placeholder: t(".phone_placeholder", default: "+491234567890"),
class: "w-full px-3 py-2 border border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent",
autocomplete: "tel" %>
<% if @traderepublic_item.errors[:phone_number].any? %>
<p class="text-xs text-error mt-1"><%= @traderepublic_item.errors[:phone_number].first %></p>
<% end %>
<p class="text-xs text-secondary mt-1">
<%= t(".phone_help", default: "International format with country code (e.g., +491234567890 for Germany)") %>
</p>
</div>
<div>
<%= f.label :pin, t(".pin_label", default: "PIN"), class: "block text-sm font-medium text-primary mb-1" %>
<%= f.password_field :pin,
placeholder: t(".pin_placeholder", default: "••••"),
class: "w-full px-3 py-2 border border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent",
autocomplete: "off" %>
<% if @traderepublic_item.errors[:pin].any? %>
<p class="text-xs text-error mt-1"><%= @traderepublic_item.errors[:pin].first %></p>
<% end %>
<p class="text-xs text-secondary mt-1">
<%= t(".pin_help", default: "Your 4-digit Trade Republic PIN") %>
</p>
</div>
<div class="p-3 bg-info/10 border border-info rounded-lg">
<p class="text-xs text-info">
<%= icon "info", class: "inline-block w-4 h-4 mr-1" %>
<%= t(".security_notice", default: "Your credentials are encrypted and stored securely. After connecting, you will receive a verification code on your phone.") %>
</p>
</div>
</div>
<div id="loading-spinner" class="hidden flex items-center justify-center gap-2 text-sm text-secondary py-2">
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
<span><%= t(".sending_code", default: "Sending verification code...") %></span>
</div>
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel", default: "Cancel"), @return_to || new_account_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= f.submit t(".connect", default: "Connect"),
id: "connect-btn",
data: { disable_with: false },
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
</div>
<% end %>
</div>
<% end %>
<% end %>
<script>
(function() {
const form = document.querySelector('form');
const loadingSpinner = document.getElementById('loading-spinner');
const connectBtn = document.getElementById('connect-btn');
form?.addEventListener('submit', (e) => {
// Show loading spinner
loadingSpinner?.classList.remove('hidden');
// Disable button
connectBtn?.setAttribute('disabled', 'disabled');
connectBtn?.classList.add('button-bg-disabled');
});
})();
</script>
<% end %>

View File

@@ -0,0 +1,56 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title", default: "Select Trade Republic Accounts")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<p class="text-sm text-secondary">
<%= t(".description", default: "Select the accounts you want to link from your Trade Republic portfolio.") %>
</p>
<form action="<%= link_accounts_traderepublic_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<%= hidden_field_tag :accountable_type, @accountable_type %>
<%= hidden_field_tag :return_to, @return_to %>
<div class="space-y-2">
<% @available_accounts.each do |account| %>
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-subtle cursor-pointer transition-colors">
<%= check_box_tag "account_ids[]", account.id, false, class: "mt-1" %>
<div class="flex-1">
<div class="font-medium text-sm text-primary">
<%= account.name %>
</div>
<div class="text-xs text-secondary mt-1">
<% if account.current_balance %>
<%= number_to_currency(account.current_balance, unit: account.currency, precision: 2) %> •
<% end %>
<%= account.currency %> • <%= account.account_type&.humanize || "Investment" %>
</div>
</div>
</label>
<% end %>
</div>
<% if @available_accounts.empty? %>
<div class="p-4 bg-warning/10 border border-warning rounded-lg">
<p class="text-sm text-warning">
<%= icon "alert-triangle", class: "inline-block w-4 h-4 mr-1" %>
<%= t(".no_accounts", default: "No accounts available for linking. All accounts may already be linked.") %>
</p>
</div>
<% end %>
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel", default: "Cancel"), @return_to || new_account_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= submit_tag t(".link_accounts", default: "Link Selected Accounts"),
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled",
disabled: @available_accounts.empty? %>
</div>
</form>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,55 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title", default: "Link Trade Republic Account")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<p class="text-sm text-secondary">
<%= t(".description", default: "Select a Trade Republic account to link with %{account_name}.", account_name: @account.name) %>
</p>
<form action="<%= link_existing_account_traderepublic_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<%= hidden_field_tag :account_id, @account.id %>
<div class="space-y-2">
<% @available_accounts.each do |tr_account| %>
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-subtle cursor-pointer transition-colors">
<%= radio_button_tag "traderepublic_account_id", tr_account.id, false, class: "mt-1" %>
<div class="flex-1">
<div class="font-medium text-sm text-primary">
<%= tr_account.name %>
</div>
<div class="text-xs text-secondary mt-1">
<% if tr_account.current_balance %>
<%= number_to_currency(tr_account.current_balance, unit: tr_account.currency, precision: 2) %> •
<% end %>
<%= tr_account.currency %> • <%= tr_account.account_type&.humanize || "Investment" %>
</div>
</div>
</label>
<% end %>
</div>
<% if @available_accounts.empty? %>
<div class="p-4 bg-warning/10 border border-warning rounded-lg">
<p class="text-sm text-warning">
<%= icon "alert-triangle", class: "inline-block w-4 h-4 mr-1" %>
<%= t(".no_accounts", default: "No Trade Republic accounts available. Please connect a new Trade Republic account first.") %>
</p>
</div>
<% end %>
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel", default: "Cancel"), account_path(@account),
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= submit_tag t(".link_account", default: "Link Account"),
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled",
disabled: @available_accounts.empty? %>
</div>
</form>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,130 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title", default: "Verify Device PIN")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="p-4 bg-accent/10 border border-accent rounded-lg">
<p class="text-sm text-primary">
<%= icon "smartphone", class: "inline-block w-5 h-5 mr-2" %>
<%= t(".instruction", default: "A verification code has been sent to your Trade Republic app. Please enter the code below.") %>
</p>
</div>
<div id="pin-form" class="space-y-4">
<div>
<label for="device_pin" class="block text-sm font-medium text-primary mb-2">
<%= t(".pin_label", default: "Verification Code") %>
</label>
<input
type="text"
id="device_pin"
name="device_pin"
placeholder="<%= t(".pin_placeholder", default: "Enter 4-digit code") %>"
maxlength="4"
pattern="[0-9]{4}"
autocomplete="off"
class="w-full px-4 py-3 border border-primary rounded-lg text-center text-2xl font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p id="error-message" class="text-xs text-error mt-2 hidden"></p>
</div>
<div id="loading-spinner" class="hidden flex items-center justify-center gap-2 text-sm text-secondary">
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
<span><%= t(".verifying", default: "Verifying...") %></span>
</div>
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel", default: "Cancel"), traderepublic_items_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<button
type="button"
id="verify-btn"
class="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled"
>
<%= t(".verify", default: "Verify") %>
</button>
</div>
</div>
</div>
<% end %>
<% end %>
<script>
(function() {
const pinInput = document.getElementById('device_pin');
const verifyBtn = document.getElementById('verify-btn');
const errorMessage = document.getElementById('error-message');
const loadingSpinner = document.getElementById('loading-spinner');
const pinForm = document.getElementById('pin-form');
// Auto-focus on input
pinInput?.focus();
// Only allow numbers
pinInput?.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/[^0-9]/g, '');
errorMessage.classList.add('hidden');
});
// Submit on Enter
pinInput?.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && pinInput.value.length === 4) {
verifyBtn?.click();
}
});
verifyBtn?.addEventListener('click', async () => {
const devicePin = pinInput.value.trim();
if (devicePin.length !== 4) {
errorMessage.textContent = '<%= t(".pin_invalid", default: "Please enter a 4-digit code") %>';
errorMessage.classList.remove('hidden');
return;
}
// Show loading
verifyBtn.disabled = true;
loadingSpinner.classList.remove('hidden');
errorMessage.classList.add('hidden');
try {
const response = await fetch('<%= complete_login_traderepublic_item_path(@traderepublic_item) %><%= "?manual_sync=1" if local_assigns[:manual_sync] || params[:manual_sync] %>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: JSON.stringify({
device_pin: devicePin,
accountable_type: '<%= params[:accountable_type] || "Investment" %>',
return_to: '<%= @return_to %>',
manual_sync: <%= (local_assigns[:manual_sync] || params[:manual_sync]) ? 'true' : 'false' %>
})
});
const data = await response.json();
if (data.success) {
// Redirect to account selection
window.location.href = data.redirect_url;
} else {
errorMessage.textContent = data.error || '<%= t(".verification_failed", default: "Verification failed. Please try again.") %>';
errorMessage.classList.remove('hidden');
verifyBtn.disabled = false;
loadingSpinner.classList.add('hidden');
pinInput.value = '';
pinInput.focus();
}
} catch (error) {
console.error('Verification error:', error);
errorMessage.textContent = '<%= t(".network_error", default: "Network error. Please try again.") %>';
errorMessage.classList.remove('hidden');
verifyBtn.disabled = false;
loadingSpinner.classList.add('hidden');
}
});
})();
</script>
<% end %>

View File

@@ -0,0 +1,92 @@
en:
settings:
providers:
traderepublic_panel:
important: "Important:"
intro: "Trade Republic integration uses an unofficial API and requires 2-factor authentication."
need_phone_pin: "You'll need your phone number and Trade Republic PIN"
verification_code: "A verification code will be sent to your Trade Republic app during setup"
sessions_stored: "Sessions are stored securely and will need to be refreshed periodically"
note: "Note:"
reverse_engineered: "This integration is based on reverse-engineered API endpoints and may break without notice. Use at your own risk."
connected_accounts: "Connected Accounts"
pending_verification: "Pending Verification"
login_required: "Login Required"
connected: "Connected"
accounts: "account(s)"
last_synced: "Last synced"
ago: "ago"
verify_pin: "Verify PIN"
manual_sync: "Manual sync"
disconnect_confirm: "Are you sure you want to disconnect this Trade Republic account?"
no_accounts: "No Trade Republic accounts connected yet. Use the 'Connect Account' button when adding a new account."
need_help: "Need help?"
guides_html: "Visit the <a href='%{url}' class='link'>guides</a> for detailed setup instructions."
traderepublic:
items:
errors:
pin_required: "PIN is required"
provider_not_configured: "TradeRepublic provider is not configured"
no_process_id: "No processId found"
provider:
name: "Trade Republic"
description: "Connect to your Trade Republic account"
new:
title: "Connect Trade Republic"
description: "Enter your Trade Republic phone number and PIN to connect your account."
name_label: "Connection Name"
name_placeholder: "Trade Republic"
phone_label: "Phone Number"
phone_placeholder: "+491234567890"
phone_help: "International format with country code (e.g., +491234567890 for Germany)"
select_accounts:
title: "Select Trade Republic Accounts"
description: "Select the accounts you want to link from your Trade Republic portfolio."
no_accounts: "No accounts available for linking. All accounts may already be linked."
select_existing_account:
title: "Link Trade Republic Account"
description: "Select a Trade Republic account to link with %{account_name}."
no_accounts: "No Trade Republic accounts available. Please connect a new Trade Republic account first."
api_error:
title: "Connection Error"
generic_error: "An error occurred while connecting to Trade Republic."
back: "Go Back"
verify_pin:
title: "Verify Device PIN"
code_sent: "Verification code sent to your Trade Republic app!"
instruction: "Please enter the 4-digit code from your Trade Republic app below."
pin_label: "Verification Code"
pin_placeholder: "Enter 4-digit code"
verifying: "Verifying..."
cancel: "Cancel"
manual_sync:
device_pin_sent: "Please check your phone for the verification PIN"
login_failed: "Manual sync failed: %{message}"
complete_login:
pin_required: "PIN is required"
sync_failed: "Connection successful but failed to fetch accounts. Please try syncing manually."
verification_failed: "PIN verification failed"
unexpected_error: "An unexpected error occurred"
no_active_connection: "No active Trade Republic connection found"
error_loading_accounts: "Failed to load accounts"
no_accounts_available: "No Trade Republic accounts available for linking"
link_accounts:
no_accounts_selected: "No accounts selected"
no_connection: "No Trade Republic connection found"
accounts_linked: "Successfully linked %{count} Trade Republic account(s)"
accounts_already_linked: "Selected accounts are already linked"
no_valid_accounts: "No valid accounts to link"
update:
updated: "Trade Republic connection updated successfully"
destroy:
scheduled_for_deletion: "Trade Republic connection scheduled for deletion"
sync:
sync_started: "Sync started"
reauthenticate:
device_pin_sent: "Please check your phone for the verification PIN"
login_failed: "Re-authentication failed: %{message}"
link_existing_account:
no_account_selected: "No Trade Republic account selected"
already_linked: "This Trade Republic account is already linked"
linked_successfully: "Trade Republic account linked successfully"

View File

@@ -510,6 +510,23 @@ Rails.application.routes.draw do
end
end
resources :traderepublic_items, only: %i[index new create show edit update destroy] do
collection do
get :select_accounts
post :link_accounts
get :select_existing_account
post :link_existing_account
end
member do
post :sync
post :reauthenticate
post :manual_sync
get :verify_pin
post :complete_login
end
end
namespace :webhooks do
post "plaid"
post "plaid_eu"

View File

@@ -0,0 +1,19 @@
class CreateTraderepublicItems < ActiveRecord::Migration[7.2]
def change
create_table :traderepublic_items, id: :uuid do |t|
t.references :family, null: false, foreign_key: true, type: :uuid
t.string :name, null: false
t.string :phone_number, null: false
t.string :pin, null: false
t.string :status, null: false, default: "good"
t.boolean :scheduled_for_deletion, null: false, default: false
t.boolean :pending_account_setup, null: false, default: false
t.datetime :sync_start_date, null: false
t.index :status
t.jsonb :raw_payload, null: false
t.timestamps
end
end
end

View File

@@ -0,0 +1,20 @@
class CreateTraderepublicAccounts < ActiveRecord::Migration[7.2]
def change
create_table :traderepublic_accounts, id: :uuid do |t|
t.references :traderepublic_item, null: false, foreign_key: true, type: :uuid
t.string :account_id
t.string :name
t.string :currency
t.decimal :current_balance, precision: 19, scale: 4
t.string :account_status
t.string :account_type
t.jsonb :raw_payload
t.jsonb :raw_transactions_payload
t.timestamps
end
add_index :traderepublic_accounts, :account_id
end
end

View File

@@ -0,0 +1,8 @@
class AddProcessIdToTraderepublicItems < ActiveRecord::Migration[7.2]
def change
add_column :traderepublic_items, :process_id, :string
add_column :traderepublic_items, :session_token, :string
add_column :traderepublic_items, :refresh_token, :string
add_column :traderepublic_items, :session_cookies, :jsonb
end
end

42
db/schema.rb generated
View File

@@ -1483,6 +1483,42 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do
t.index ["message_id"], name: "index_tool_calls_on_message_id"
end
create_table "traderepublic_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "traderepublic_item_id", null: false
t.string "account_id"
t.string "name"
t.string "currency"
t.decimal "current_balance", precision: 19, scale: 4
t.string "account_status"
t.string "account_type"
t.jsonb "raw_payload"
t.jsonb "raw_transactions_payload"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_traderepublic_accounts_on_account_id"
t.index ["traderepublic_item_id"], name: "index_traderepublic_accounts_on_traderepublic_item_id"
end
create_table "traderepublic_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "name"
t.string "phone_number"
t.string "pin"
t.string "status", default: "good"
t.boolean "scheduled_for_deletion", default: false
t.boolean "pending_account_setup", default: false
t.datetime "sync_start_date"
t.jsonb "raw_payload"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "process_id"
t.string "session_token"
t.string "refresh_token"
t.jsonb "session_cookies"
t.index ["family_id"], name: "index_traderepublic_items_on_family_id"
t.index ["status"], name: "index_traderepublic_items_on_status"
end
create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "security_id", null: false
t.decimal "qty", precision: 24, scale: 8
@@ -1672,6 +1708,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do
add_foreign_key "taggings", "tags"
add_foreign_key "tags", "families"
add_foreign_key "tool_calls", "messages"
<<<<<<< HEAD
=======
add_foreign_key "traderepublic_accounts", "traderepublic_items"
add_foreign_key "traderepublic_items", "families"
add_foreign_key "trades", "categories"
>>>>>>> Add TradeRepublic provider
add_foreign_key "trades", "securities"
add_foreign_key "transactions", "categories", on_delete: :nullify
add_foreign_key "transactions", "merchants"

View File

@@ -0,0 +1,45 @@
# Trade Republic Provider Description
## Overview
The Trade Republic provider enables automatic synchronization of Trade Republic accounts and transactions into the Sure application, using an unofficial WebSocket integration.
## Key Features
- **2FA Authentication**: Secure login with phone number, PIN, and code received from the Trade Republic app.
- **Session Management**: Encrypted storage of tokens and cookies, support for processId in the authentication flow.
- **Account & Transaction Import**: Fetches portfolio, balances, and transaction history via WebSocket.
- **Automated Sync**: Manual or Sidekiq-triggered sync, orchestrated by dedicated jobs and services.
- **Modular Architecture**: Dedicated models, services, jobs, and controllers, following Rails and project conventions.
## Technical Architecture
- **Main Models**: `TraderepublicItem` (connection), `TraderepublicAccount` (account), `Provider::Traderepublic` (WebSocket client).
- **Services**: Importer, Syncer, Processor for importing, syncing, and parsing data.
- **Jobs**: `TraderepublicItem::SyncJob` for background synchronization.
- **Security**: Credentials and tokens encrypted via ActiveRecord Encryption, strict handling of sensitive data.
## Limitations & Considerations
- Unofficial API: Subject to change, no automatic refresh token yet.
- Incomplete transaction and holdings parser: To be improved as needed.
- Blocking WebSocket: Uses EventMachine, may impact scalability.
- Manual authentication possible: Token extraction via browser if API issues occur.
## Deployment & Usage
- Required gems and migrations must be installed.
- ActiveRecord Encryption keys must be configured.
- Connection and sync tests via UI or Rails console.
- Monitoring via logs and Sidekiq.
## Related Documentation
- [Deployment Guide](docs/providers/TRADEREPUBLIC_DEPLOYMENT.md)
- [Quick Start](docs/providers/TRADEREPUBLIC_QUICKSTART.md)
- [Manual Authentication](docs/providers/TRADEREPUBLIC_MANUAL_AUTH.md)
- [Technical Documentation](docs/providers/TRADEREPUBLIC.md)
---
Feel free to adapt or extend this according to your PR context or documentation target.

View File

@@ -0,0 +1,31 @@
require "test_helper"
class TradeRepublic::SecurityResolverTest < ActiveSupport::TestCase
setup do
Holding.delete_all
Security::Price.delete_all
Trade.delete_all
Security.delete_all
end
test "returns existing security by ISIN in name" do
security = Security.create!(name: "Apple Inc. US0378331005", ticker: "AAPL1", exchange_operating_mic: "XNAS")
resolver = TradeRepublic::SecurityResolver.new("US0378331005")
assert_equal security, resolver.resolve
end
test "creates new security if not found" do
resolver = TradeRepublic::SecurityResolver.new("US0000000001", name: "Test Security", ticker: "TEST1", mic: "XTST")
security = resolver.resolve
assert security.persisted?
assert_equal "Test Security (US0000000001)", security.name
assert_equal "TEST1", security.ticker
assert_equal "XTST", security.exchange_operating_mic
end
test "returns existing security if ticker/mic already taken" do
existing = Security.create!(name: "Existing", ticker: "DUPL1", exchange_operating_mic: "XDUP")
resolver = TradeRepublic::SecurityResolver.new("US0000000002", name: "Other", ticker: "DUPL1", mic: "XDUP")
assert_equal existing, resolver.resolve
end
end