diff --git a/Gemfile b/Gemfile index de23ef7d8..b15898369 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock index b962e3d28..00e4a6f52 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -199,6 +199,7 @@ GEM erubi (1.13.1) et-orbi (1.2.11) tzinfo + event_emitter (0.2.6) event_stream_parser (1.0.0) faker (3.5.2) i18n (>= 1.8.11, < 2) @@ -760,6 +761,11 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) + websocket-client-simple (0.9.0) + base64 + event_emitter + mutex_m + websocket websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -875,6 +881,7 @@ DEPENDENCIES view_component web-console webmock + websocket-client-simple RUBY VERSION ruby 3.4.7p58 diff --git a/app/controllers/traderepublic_items_controller.rb b/app/controllers/traderepublic_items_controller.rb new file mode 100644 index 000000000..a845c5751 --- /dev/null +++ b/app/controllers/traderepublic_items_controller.rb @@ -0,0 +1,495 @@ +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 = safe_return_to_path + 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 = safe_return_to_path + + 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 + @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 + # Provider filtering is strict greater-than; reusing last_date prevents skipping a day. + since = last_date + 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 + + def verify_pin + render layout: false + 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 diff --git a/app/javascript/controllers/traderepublic_reauth_controller.js b/app/javascript/controllers/traderepublic_reauth_controller.js new file mode 100644 index 000000000..77d7c6265 --- /dev/null +++ b/app/javascript/controllers/traderepublic_reauth_controller.js @@ -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 + } +} diff --git a/app/jobs/traderepublic_item/sync_job.rb b/app/jobs/traderepublic_item/sync_job.rb new file mode 100644 index 000000000..fc7a7dcd2 --- /dev/null +++ b/app/jobs/traderepublic_item/sync_job.rb @@ -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 diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 633c26f1b..970d802e6 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -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 diff --git a/app/models/concerns/traderepublic_session_configurable.rb b/app/models/concerns/traderepublic_session_configurable.rb new file mode 100644 index 000000000..8d0ee8170 --- /dev/null +++ b/app/models/concerns/traderepublic_session_configurable.rb @@ -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 diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index db09f81b5..38d91115a 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -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", sophtron: "sophtron" } + 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", sophtron: "sophtron" } end diff --git a/app/models/family.rb b/app/models/family.rb index 3a3547b58..b443832c2 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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, SophtronConnectable include IndexaCapitalConnectable diff --git a/app/models/family/traderepublic_connectable.rb b/app/models/family/traderepublic_connectable.rb new file mode 100644 index 000000000..ab14a10dd --- /dev/null +++ b/app/models/family/traderepublic_connectable.rb @@ -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 diff --git a/app/models/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb index e231fb33c..26542c031 100644 --- a/app/models/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -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 diff --git a/app/models/provider/traderepublic.rb b/app/models/provider/traderepublic.rb new file mode 100644 index 000000000..192143261 --- /dev/null +++ b/app/models/provider/traderepublic.rb @@ -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" + 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_size=#{response.body.to_s.bytesize}" + + 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 diff --git a/app/models/provider/traderepublic_adapter.rb b/app/models/provider/traderepublic_adapter.rb new file mode 100644 index 000000000..ea5771f1f --- /dev/null +++ b/app/models/provider/traderepublic_adapter.rb @@ -0,0 +1,84 @@ +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 + +end diff --git a/app/models/trade_republic/security_resolver.rb b/app/models/trade_republic/security_resolver.rb new file mode 100644 index 000000000..a8d97d149 --- /dev/null +++ b/app/models/trade_republic/security_resolver.rb @@ -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 diff --git a/app/models/traderepublic_account.rb b/app/models/traderepublic_account.rb new file mode 100644 index 000000000..5f22bdf92 --- /dev/null +++ b/app/models/traderepublic_account.rb @@ -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 diff --git a/app/models/traderepublic_account/processor.rb b/app/models/traderepublic_account/processor.rb new file mode 100644 index 000000000..149a2c53b --- /dev/null +++ b/app/models/traderepublic_account/processor.rb @@ -0,0 +1,595 @@ +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.where(source: "traderepublic").delete_all + Rails.logger.info "TraderepublicAccount::Processor - #{deleted_count} Trade Republic 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") + raise + 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") + raise + 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 + + quantity_token = quantity_str.to_s.split.first + cleaned = quantity_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 diff --git a/app/models/traderepublic_error.rb b/app/models/traderepublic_error.rb new file mode 100644 index 000000000..97d5f600c --- /dev/null +++ b/app/models/traderepublic_error.rb @@ -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 diff --git a/app/models/traderepublic_item.rb b/app/models/traderepublic_item.rb new file mode 100644 index 000000000..eb701b7f7 --- /dev/null +++ b/app/models/traderepublic_item.rb @@ -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) + 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! + true + else + sync.fail! + sync.update(error: "Import failed") + 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") + false + end + end +end diff --git a/app/models/traderepublic_item/importer.rb b/app/models/traderepublic_item/importer.rb new file mode 100644 index 000000000..80dae94e1 --- /dev/null +++ b/app/models/traderepublic_item/importer.rb @@ -0,0 +1,283 @@ + +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 + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching transactions" + + account = traderepublic_item.traderepublic_accounts.first + return unless account + + since_date = account.last_transaction_date + if account.linked_account.nil? || !account.linked_account.transactions.exists? + since_date = nil + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Forcing initial full sync (no transactions exist)" + elsif since_date + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Incremental sync from #{since_date}" + else + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Initial full sync" + end + + transactions_data = provider.get_timeline_transactions(since: since_date) + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data class=#{transactions_data.class} keys=#{transactions_data.respond_to?(:keys) ? transactions_data.keys : "n/a"}" + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data preview=#{transactions_data.inspect[0..300]}" + return unless transactions_data + + parsed = transactions_data.is_a?(String) ? JSON.parse(transactions_data) : transactions_data + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: parsed class=#{parsed.class} keys=#{parsed.respond_to?(:keys) ? parsed.keys : "n/a"}" + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: parsed preview=#{parsed.inspect[0..300]}" + + 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| + 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 + + 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 + + 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}" + + account.upsert_traderepublic_transactions_snapshot!(parsed) + Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Snapshot saved with #{items_count} items." + + process_transactions(account, items) + rescue JSON::ParserError => e + Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse transactions - #{e.message}" + raise + 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 diff --git a/app/models/traderepublic_item/provided.rb b/app/models/traderepublic_item/provided.rb new file mode 100644 index 000000000..3d37f3a62 --- /dev/null +++ b/app/models/traderepublic_item/provided.rb @@ -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 diff --git a/app/models/traderepublic_item/sync_complete_event.rb b/app/models/traderepublic_item/sync_complete_event.rb new file mode 100644 index 000000000..e6a71b429 --- /dev/null +++ b/app/models/traderepublic_item/sync_complete_event.rb @@ -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 diff --git a/app/models/traderepublic_item/syncer.rb b/app/models/traderepublic_item/syncer.rb new file mode 100644 index 000000000..6e31bead6 --- /dev/null +++ b/app/models/traderepublic_item/syncer.rb @@ -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 diff --git a/app/views/settings/providers/_traderepublic_panel.html.erb b/app/views/settings/providers/_traderepublic_panel.html.erb new file mode 100644 index 000000000..337e1b45b --- /dev/null +++ b/app/views/settings/providers/_traderepublic_panel.html.erb @@ -0,0 +1,102 @@ +
<%= t("settings.providers.traderepublic_panel.important", default: "Important:") %> <%= t("settings.providers.traderepublic_panel.intro", default: "Trade Republic integration uses an unofficial API and requires 2-factor authentication.") %>
+ ++ <%= icon "alert-triangle", class: "inline-block w-4 h-4 mr-1" %> + <%= t("settings.providers.traderepublic_panel.note", default: "Note:") %> <%= 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.") %> +
++ <%= t("settings.providers.traderepublic_panel.no_accounts", default: "No Trade Republic accounts connected yet. Use the 'Connect Account' button when adding a new account.") %> +
+<%= t("settings.providers.traderepublic_panel.need_help", default: "Need help?") %> <%= t("settings.providers.traderepublic_panel.guides_html", default: "Visit the guides for detailed setup instructions.", url: settings_guides_path).html_safe %>
++ <%= 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.") %> +
++ <%= icon "check-circle", class: "inline-block w-5 h-5 mr-2" %> + <%= t(".code_sent", default: "Verification code sent to your Trade Republic app!") %> +
++ <%= 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.") %> +
+<%= @traderepublic_item.errors[:name].first %>
+ <% end %> ++ <%= 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.") %> +
+<%= t(".description", default: "Manage your Trade Republic broker connections") %>
++ <%= icon "phone", class: "w-4 h-4 inline mr-1" %> + <%= item.phone_number %> +
+ <% if item.last_synced_at %> ++ <%= icon "refresh-cw", class: "w-4 h-4 inline mr-1" %> + Last synced <%= time_ago_in_words(item.last_synced_at) %> ago +
+ <% end %> +Linked Accounts:
++ <%= t(".no_connections_description", default: "Connect your Trade Republic account to sync your portfolio and transactions") %> +
+ <%= 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 %> ++ <%= t(".description", default: "Enter your Trade Republic phone number and PIN to connect your account.") %> +
+ + <%= 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 %> + +<%= @traderepublic_item.errors[:name].first %>
+ <% end %> +<%= @traderepublic_item.errors[:phone_number].first %>
+ <% end %> ++ <%= t(".phone_help", default: "International format with country code (e.g., +491234567890 for Germany)") %> +
+<%= @traderepublic_item.errors[:pin].first %>
+ <% end %> ++ <%= t(".pin_help", default: "Your 4-digit Trade Republic PIN") %> +
++ <%= 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.") %> +
++ <%= t(".description", default: "Select the accounts you want to link from your Trade Republic portfolio.") %> +
+ + ++ <%= t(".description", default: "Select a Trade Republic account to link with %{account_name}.", account_name: @account.name) %> +
+ + ++ <%= 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.") %> +
+