diff --git a/Gemfile b/Gemfile index 652f91920..84369925a 100644 --- a/Gemfile +++ b/Gemfile @@ -86,6 +86,7 @@ gem "httparty" gem "rotp", "~> 6.3" gem "rqrcode", "~> 3.0" gem "webauthn", "~> 3.4" +gem "websocket-driver", "~> 0.8" gem "activerecord-import" gem "rubyzip", "~> 2.3" gem "pdf-reader", "~> 2.12" diff --git a/Gemfile.lock b/Gemfile.lock index f360997d8..18010a4d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -969,6 +969,7 @@ DEPENDENCIES web-console webauthn (~> 3.4) webmock + websocket-driver (~> 0.8) RUBY VERSION ruby 3.4.9p82 diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 7fbf4538d..981ca499f 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -35,6 +35,9 @@ class AccountsController < ApplicationController @kraken_items = visible_provider_items(family.kraken_items.ordered.with_attached_logo.includes(:kraken_accounts, :accounts)) @questrade_items = visible_provider_items(family.questrade_items.ordered.with_attached_logo.includes(:accounts, questrade_accounts: :account_provider)) @wise_items = visible_provider_items(family.wise_items.ordered.includes(:wise_accounts, :accounts)) + @trade_republic_items = visible_provider_items( + family.trade_republic_items.ordered.includes(trade_republic_accounts: { account_provider: :account }) + ) # An on-chain item is admitted as soon as ONE of its accounts is accessible, # so the card is told which of them this viewer may actually see. nil is the @@ -353,6 +356,7 @@ class AccountsController < ApplicationController @kraken_items, @questrade_items, @wise_items, + @trade_republic_items, @onchain_wallet_items ].flatten.compact diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb index 45a89f938..a74fa9ab7 100644 --- a/app/controllers/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -6,6 +6,7 @@ class HoldingsController < ApplicationController def index @account = accessible_accounts.find(params[:account_id]) + @trade_republic_categories = trade_republic_categories_for(@account) end def show @@ -147,6 +148,26 @@ class HoldingsController < ApplicationController end private + + def trade_republic_categories_for(account) + provider = account.account_providers.includes(:provider).map(&:provider).find do |candidate| + candidate.is_a?(TradeRepublicAccount) && candidate.portfolio? + end + return if provider.blank? + + values = Array(provider.raw_positions_payload).group_by { |position| position["category"].presence || "brokerage" } + TradeRepublicClientCategories::ALL.index_with do |category| + positions = values.fetch(category, []) + { + count: positions.size, + value: positions.sum do |position| + quantity = position["quantity"].presence&.to_d || BigDecimal("0") + price = position["price"].presence&.to_d || BigDecimal("0") + quantity * price + end + } + end + end def set_holding @holding = Current.family.holdings .joins(:account) diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 71e93f756..97845df66 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -205,6 +205,7 @@ class Settings::ProvidersController < ApplicationController { key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" }, { key: "ibkr", title: "Interactive Brokers", turbo_id: "ibkr", partial: "ibkr_panel" }, { key: "trading212", title: "Trading 212", turbo_id: "trading212", partial: "trading212_panel" }, + { key: "trade_republic", title: "Trade Republic", turbo_id: "trade-republic", partial: "trade_republic_panel" }, { key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" }, { key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" }, { key: "questrade", title: "Questrade", turbo_id: "questrade", partial: "questrade_panel" } @@ -232,6 +233,7 @@ class Settings::ProvidersController < ApplicationController "questrade" => "QuestradeItem", "ibkr" => "IbkrItem", "trading212" => "Trading212Item", + "trade_republic" => "TradeRepublicItem", "indexa_capital" => "IndexaCapitalItem", "sophtron" => "SophtronItem" }.freeze @@ -272,6 +274,8 @@ class Settings::ProvidersController < ApplicationController @ibkr_items = Current.family.ibkr_items.ordered when "trading212" @trading212_items = Current.family.trading212_items.ordered + when "trade_republic" + @trade_republic_items = Current.family.trade_republic_items.ordered when "indexa_capital" @indexa_capital_items = Current.family.indexa_capital_items.ordered when "sophtron" @@ -306,6 +310,7 @@ class Settings::ProvidersController < ApplicationController @snaptrade_items = Current.family.snaptrade_items.ordered @ibkr_items = Current.family.ibkr_items.ordered.select(:id) @trading212_items = Current.family.trading212_items.ordered.select(:id) + @trade_republic_items = Current.family.trade_republic_items.ordered.select(:id) @indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id) @binance_items = Current.family.binance_items.active.ordered @kraken_items = Current.family.kraken_items.active.ordered @@ -346,6 +351,7 @@ class Settings::ProvidersController < ApplicationController "questrade" => @questrade_items, "ibkr" => @ibkr_items, "trading212" => @trading212_items, + "trade_republic" => @trade_republic_items, "indexa_capital" => @indexa_capital_items, "sophtron" => @sophtron_items } diff --git a/app/controllers/trade_republic_items_controller.rb b/app/controllers/trade_republic_items_controller.rb new file mode 100644 index 000000000..65322ac7f --- /dev/null +++ b/app/controllers/trade_republic_items_controller.rb @@ -0,0 +1,564 @@ +class TradeRepublicItemsController < ApplicationController + before_action :set_trade_republic_item, only: [ :show, :update, :destroy, :sync, :repair, :setup_accounts, :complete_account_setup, :initiate_login, :complete_login, :poll_login, :initiate_qr_login, :poll_qr_login, :cancel_qr_login ] + before_action :require_admin!, only: [ :show, :create, :select_accounts, :select_existing_account, :link_existing_account, :update, :destroy, :sync, :repair, :setup_accounts, :complete_account_setup, :initiate_login, :complete_login, :poll_login, :initiate_qr_login, :poll_qr_login, :cancel_qr_login ] + + def show + redirect_to settings_providers_path(anchor: "trade-republic") + end + + def create + qr_login_requested = params[:login_method] == "qr" || params.dig(:trade_republic_item, :login_method) == "qr" + @trade_republic_item = Current.family.trade_republic_items.build(trade_republic_item_params) + login_pin = @trade_republic_item.pin + @trade_republic_item.name ||= t("trade_republic_items.defaults.name") + @trade_republic_item.currency ||= Current.family.currency + @trade_republic_item.status = :requires_update if qr_login_requested + + if !qr_login_requested && login_pin.blank? + render_panel_error(t("trade_republic_items.initiate_login.pin_required")) + return + end + + if @trade_republic_item.save + if qr_login_requested + initiate_qr_login_for(@trade_republic_item) + else + initiate_login_for(@trade_republic_item, pin: login_pin) + end + + if turbo_panel_request? + if @error_message.present? + flash.now[:alert] = @error_message + else + flash.now[:notice] = t(".success") + end + render turbo_stream: [ + turbo_stream.replace( + "trade-republic-providers-panel", + partial: "settings/providers/trade_republic_panel", + locals: { + trade_republic_item: @trade_republic_item, + qr_code_svg: @qr_code_svg, + qr_login_auto_poll: qr_login_requested && @error_message.blank? + } + ), + *flash_notification_stream_items + ] + elsif @error_message.present? + redirect_to settings_providers_path(anchor: "trade-republic"), alert: @error_message, status: :see_other + else + redirect_to accounts_path, **redirect_flash_options(t(".success")), status: :see_other + end + else + render_panel_error(@trade_republic_item.errors.full_messages.join(", ")) + end + end + + # Step 1 of authentication: starts a Trade Republic web login and stores the + # in-flight login state (encrypted) on the item. The user then confirms via + # push notification or authenticator code; no web request blocks on that. + def initiate_login + provider = @trade_republic_item.trade_republic_provider + unless provider + redirect_to settings_providers_path, alert: t(".not_configured"), status: :see_other + return + end + + if @trade_republic_item.pin.blank? + @trade_republic_item.update!(pending_login_state: nil, status: :requires_update) if @trade_republic_item.pending_login_state.present? + return render_login_panel(alert: t(".pin_required")) if turbo_panel_request? + + respond_to do |format| + format.html { redirect_to settings_providers_path(anchor: "trade-republic"), alert: t(".pin_required"), status: :see_other } + format.json { render json: { error: t(".pin_required") }, status: :unprocessable_entity } + end + return + end + + invalidate_authentication! + result = @trade_republic_item.trade_republic_provider.initiate_login + @trade_republic_item.update!( + pending_login_state: result["pending_login_b64"], + status: :requires_update + ) + + respond_to do |format| + format.html { redirect_to settings_providers_path(anchor: "trade-republic"), notice: t(".verification_required", method: result["method"]) } + format.json { head :ok } + end + rescue Provider::TradeRepublicClient::Error => e + redirect_to settings_providers_path(anchor: "trade-republic"), alert: e.message, status: :see_other + end + + # Step 2 of authentication: completes the started login. For push accounts + # this is retried until Trade Republic reports CONFIRMED (status "pending"); + # authenticator accounts submit their code here. Success replaces the stored + # session blob and clears the pending login state. + def complete_login + provider = @trade_republic_item.trade_republic_provider + unless provider && @trade_republic_item.pending_login_state.present? + redirect_to settings_providers_path(anchor: "trade-republic"), alert: t(".no_pending_login"), status: :see_other + return + end + + result = provider.complete_login( + pending_login_b64: @trade_republic_item.pending_login_state, + code: trade_republic_login_params[:code] + ) + + if result.data["status"] == "pending" + @trade_republic_item.update!(pending_login_state: result.data.fetch("pending_login_b64")) if result.data["pending_login_b64"].present? + if turbo_panel_request? + render_login_panel(notice: t(".approval_pending")) + else + redirect_to settings_providers_path(anchor: "trade-republic"), notice: t(".approval_pending") + end + else + ActiveRecord::Base.transaction do + @trade_republic_item.update!( + session_blob: result.data.fetch("session_txt"), + pending_login_state: nil, + status: :good + ) + end + @trade_republic_item.sync_later unless @trade_republic_item.syncing? + if turbo_panel_request? + render_login_panel(success: true) + else + redirect_to settings_providers_path(anchor: "trade-republic"), notice: t(".success") + end + end + rescue Provider::TradeRepublicClient::InvalidChallenge => e + redirect_to settings_providers_path(anchor: "trade-republic"), alert: e.message, status: :see_other + rescue Provider::TradeRepublicClient::LoginExpired, Provider::TradeRepublicClient::AuthenticationRequired + @trade_republic_item.update!(pending_login_state: nil) + redirect_to settings_providers_path(anchor: "trade-republic"), alert: t(".login_expired"), status: :see_other + rescue Provider::TradeRepublicClient::Error => e + redirect_to settings_providers_path(anchor: "trade-republic"), alert: e.message, status: :see_other + end + + # Push approvals are asynchronous. The browser polls this endpoint while + # the user approves the login in the Trade Republic app (maximum two minutes). + def poll_login + provider = @trade_republic_item.trade_republic_provider + unless provider && @trade_republic_item.pending_login_state.present? + return render_login_panel(alert: t(".no_pending_login")) + end + + result = provider.complete_login(pending_login_b64: @trade_republic_item.pending_login_state) + if result.data["status"] == "pending" + @trade_republic_item.update!(pending_login_state: result.data.fetch("pending_login_b64")) if result.data["pending_login_b64"].present? + render_login_panel + else + @trade_republic_item.update!(session_blob: result.data.fetch("session_txt"), pending_login_state: nil, status: :good) + @trade_republic_item.sync_later unless @trade_republic_item.syncing? + render_login_panel(success: true) + end + rescue Provider::TradeRepublicClient::LoginExpired, Provider::TradeRepublicClient::AuthenticationRequired + @trade_republic_item.update!(pending_login_state: nil) + render_login_panel(alert: t(".login_expired")) + rescue Provider::TradeRepublicClient::Error => e + render_login_panel(alert: e.message) + end + + def initiate_qr_login + provider = @trade_republic_item.trade_republic_provider + raise Provider::TradeRepublicClient::ConfigurationError, t(".not_configured") unless provider + + invalidate_authentication! + result = provider.initiate_qr_login + @trade_republic_item.update!(pending_login_state: result.data.fetch("pending_login_b64"), status: :requires_update) + response_data = result.data.except("pending_login_b64") + response_data["qr_code_svg"] = view_context.generate_mfa_qr_code(result.data["qr_code_payload"]) if result.data["qr_code_payload"].present? + render json: response_data, status: :accepted + rescue Provider::TradeRepublicClient::Error => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def poll_qr_login + provider = @trade_republic_item.trade_republic_provider + pending = @trade_republic_item.pending_login_state + raise Provider::TradeRepublicClient::InvalidChallenge, t(".no_pending_login") if provider.blank? || pending.blank? + + result = provider.poll_qr_login(pending_login_b64: pending) + if result.data["status"] == "pending" + @trade_republic_item.update!(pending_login_state: result.data.fetch("pending_login_b64")) + response_data = result.data.except("pending_login_b64") + response_data["qr_code_svg"] = view_context.generate_mfa_qr_code(result.data["qr_code_payload"]) if result.data["qr_code_payload"].present? + render json: response_data + else + @trade_republic_item.update!(session_blob: result.data.fetch("session_txt"), pending_login_state: nil, status: :good) + @trade_republic_item.sync_later unless @trade_republic_item.syncing? + flash[:notice] = t(".success") + render json: result.data.except("session_txt") + end + rescue Provider::TradeRepublicClient::LoginExpired, Provider::TradeRepublicClient::AuthenticationRequired => e + @trade_republic_item.update!(pending_login_state: nil) + render json: { error: e.message }, status: :unprocessable_entity + rescue Provider::TradeRepublicClient::RateLimited => e + render_qr_login_error(e, status: :too_many_requests, retryable: true) + rescue Provider::TradeRepublicClient::Timeout, + Provider::TradeRepublicClient::TransientProviderError => e + render_qr_login_error(e, status: :service_unavailable, retryable: true) + rescue Provider::TradeRepublicClient::Error => e + render_qr_login_error(e) + end + + def cancel_qr_login + @trade_republic_item.update!(pending_login_state: nil, status: :requires_update) + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "trade-republic-providers-panel", + partial: "settings/providers/trade_republic_panel", + locals: { trade_republic_item: @trade_republic_item } + ) + end + format.json { render json: { status: "cancelled" } } + end + end + + def update + attrs = trade_republic_item_params.to_h + login_pin = attrs["pin"] + reauthentication_required = reauthentication_needed?(attrs) + + if reauthentication_required && login_pin.blank? + render_panel_error(t(".pin_required")) + return + end + + if @trade_republic_item.update(attrs) + initiate_login_for(@trade_republic_item, pin: login_pin) if reauthentication_required + + if turbo_panel_request? + if @error_message.present? + flash.now[:alert] = @error_message + else + flash.now[:notice] = t(".success") + end + render turbo_stream: [ + turbo_stream.replace( + "trade-republic-providers-panel", + partial: "settings/providers/trade_republic_panel", + locals: { trade_republic_item: @trade_republic_item } + ), + *flash_notification_stream_items + ] + elsif @error_message.present? + redirect_to settings_providers_path(anchor: "trade-republic"), alert: @error_message, status: :see_other + else + redirect_to accounts_path, **redirect_flash_options(t(".success")), status: :see_other + end + else + render_panel_error(@trade_republic_item.errors.full_messages.join(", ")) + end + end + + def destroy + unlink_results = @trade_republic_item.unlink_all!(dry_run: false) + if unlink_results.any? { |result| result[:error].present? } + redirect_to settings_providers_path, alert: t(".unlink_failed"), status: :see_other + return + end + + @trade_republic_item.destroy_later + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + + def sync + @trade_republic_item.sync_later unless @trade_republic_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + def repair + TradeRepublicRepairJob.perform_later(@trade_republic_item) + redirect_back_or_to accounts_path, notice: t("trade_republic_items.repair.scheduled"), status: :see_other + end + + def select_accounts + item = current_trade_republic_item + unless item + redirect_to settings_providers_path, alert: t(".not_configured") + return + end + + redirect_to setup_accounts_trade_republic_item_path(item) + end + + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + @available_trade_republic_accounts = Current.family.trade_republic_items + .active + .includes(trade_republic_accounts: { account_provider: :account }) + .flat_map(&:trade_republic_accounts) + .select { |tr_account| tr_account.account_provider.nil? } + .sort_by { |tr_account| tr_account.updated_at || tr_account.created_at } + .reverse + + render :select_existing_account, layout: false + end + + def link_existing_account + account = Current.family.accounts.find_by(id: params[:account_id]) + item = Current.family.trade_republic_items.active.joins(:trade_republic_accounts) + .where(trade_republic_accounts: { id: params[:trade_republic_account_id] }) + .first + tr_account = item&.trade_republic_accounts&.find_by(id: params[:trade_republic_account_id]) + + if account.blank? || tr_account.blank? + redirect_to settings_providers_path, alert: t(".not_found") + return + end + + unless account.accountable_type.in?(%w[Investment Depository]) && + account.account_providers.none? && + account.plaid_account_id.blank? && + account.simplefin_account_id.blank? + redirect_to account_path(account), alert: t(".only_manual_investment") + return + end + + provider = nil + + tr_account.with_lock do + if tr_account.current_account.present? + redirect_to account_path(account), alert: t(".already_linked") + return + end + + provider = tr_account.ensure_account_provider!(account) + end + + raise "Failed to create AccountProvider link" unless provider + + processing_failed = false + begin + TradeRepublicAccount::Processor.new(tr_account.reload).process + rescue => e + processing_failed = true + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "Failed to process linked Trade Republic account #{tr_account.id}: #{e.class} - #{e.message}", + source: "trade_republic", + family: Current.family, + provider_key: "trade_republic" + ) + end + + tr_account.trade_republic_item.sync_later unless tr_account.trade_republic_item.syncing? + if processing_failed + redirect_to account_path(account), notice: t(".success"), alert: t(".processing_failed"), status: :see_other + else + redirect_to account_path(account), notice: t(".success"), status: :see_other + end + rescue => e + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "Failed to link existing Trade Republic account: #{e.class} - #{e.message}", + source: "trade_republic", + family: Current.family, + provider_key: "trade_republic" + ) + redirect_to settings_providers_path, alert: t(".failed"), status: :see_other + end + + def setup_accounts + @trade_republic_accounts = @trade_republic_item.trade_republic_accounts.includes(account_provider: :account) + @linked_accounts = @trade_republic_accounts.select { |a| a.current_account.present? } + @unlinked_accounts = @trade_republic_accounts.reject { |a| a.current_account.present? } + + no_accounts = @linked_accounts.blank? && @unlinked_accounts.blank? + latest_sync = @trade_republic_item.syncs.ordered.first + should_sync = latest_sync.nil? || !latest_sync.completed? + + if no_accounts && !@trade_republic_item.syncing? && should_sync && @trade_republic_item.session_configured? + @trade_republic_item.sync_later + end + + @linkable_accounts = Current.family.accounts + .visible + .where(accountable_type: %w[Investment Depository]) + .left_joins(:account_providers) + .where(account_providers: { id: nil }) + .order(:name) + + @syncing = @trade_republic_item.syncing? + @waiting_for_sync = no_accounts && @syncing + @no_accounts_found = no_accounts && !@syncing && @trade_republic_item.last_synced_at.present? + end + + def complete_account_setup + selected_accounts = Array(params[:account_ids]).reject(&:blank?) + created_accounts = [] + failed_accounts = 0 + failed_processing = 0 + + selected_accounts.each do |tr_account_id| + tr_account = @trade_republic_item.trade_republic_accounts.find_by(id: tr_account_id) + next unless tr_account + + begin + tr_account.with_lock do + next if tr_account.current_account.present? + + account = Account.create_from_trade_republic_account(tr_account) + provider = tr_account.ensure_account_provider!(account) + raise ActiveRecord::RecordNotSaved, "Failed to link Trade Republic account" unless provider + + created_accounts << account + end + rescue => e + failed_accounts += 1 + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "Failed to create Trade Republic account #{tr_account.id}: #{e.class} - #{e.message}", + source: "trade_republic", + family: Current.family, + provider_key: "trade_republic" + ) + next + end + + begin + TradeRepublicAccount::Processor.new(tr_account.reload).process + rescue => e + failed_processing += 1 + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "Failed to process Trade Republic account #{tr_account.id} after setup: #{e.class} - #{e.message}", + source: "trade_republic", + family: Current.family, + provider_key: "trade_republic" + ) + end + end + + @trade_republic_item.update!(pending_account_setup: @trade_republic_item.unlinked_accounts_count.positive?) + @trade_republic_item.sync_later if created_accounts.any? + + if created_accounts.any? + options = { notice: t(".success", count: created_accounts.count), status: :see_other } + failed_count = failed_accounts + failed_processing + options[:alert] = t(".partial_failure", count: failed_count) if failed_count.positive? + redirect_to accounts_path, **options + elsif selected_accounts.empty? + redirect_to setup_accounts_trade_republic_item_path(@trade_republic_item), alert: t(".none_selected"), status: :see_other + else + message = failed_accounts.positive? ? t(".partial_failure", count: failed_accounts) : t(".none_created") + redirect_to setup_accounts_trade_republic_item_path(@trade_republic_item), alert: message, status: :see_other + end + end + + private + + def initiate_qr_login_for(item) + result = item.trade_republic_provider.initiate_qr_login + item.update!(pending_login_state: result.data.fetch("pending_login_b64"), status: :requires_update) + @qr_code_svg = view_context.generate_mfa_qr_code(result.data["qr_code_payload"]) if result.data["qr_code_payload"].present? + rescue Provider::TradeRepublicClient::Error => e + @error_message = e.message + end + + def set_trade_republic_item + @trade_republic_item = Current.family.trade_republic_items.find(params[:id]) + end + + def current_trade_republic_item + active_items = Current.family.trade_republic_items.active + active_items.syncable.ordered.first || active_items.ordered.first + end + + def trade_republic_item_params + params.require(:trade_republic_item).permit(:phone_number, :pin, :currency) + end + + # Credentials changed → the stored session belongs to the old login. + def reauthentication_needed?(attrs) + (attrs["phone_number"].present? && attrs["phone_number"] != @trade_republic_item.phone_number) || attrs["pin"].present? + end + + def initiate_login_for(item, pin: nil) + item.update!(session_blob: nil, pending_login_state: nil, status: :requires_update) + provider = item.trade_republic_provider(pin: pin) + return unless provider + + result = provider.initiate_login + item.update!(pending_login_state: result["pending_login_b64"], status: :requires_update) + rescue Provider::TradeRepublicClient::Error => e + @error_message = e.message + DebugLogEntry.capture( + category: "sync", + level: "warn", + message: "Trade Republic login initiation failed for item #{item.id}: #{e.message}", + source: "trade_republic", + family: item.family, + provider_key: "trade_republic" + ) + end + + def invalidate_authentication! + @trade_republic_item.update!(session_blob: nil, pending_login_state: nil, status: :requires_update) + end + + def redirect_flash_options(success_message) + @error_message.present? ? { alert: @error_message } : { notice: success_message } + end + + def trade_republic_login_params + params.fetch(:trade_republic_login, {}).permit(:code) + end + + def render_login_panel(alert: nil, notice: nil, success: false) + if success + return render turbo_stream: [ + turbo_stream.replace("drawer", view_context.turbo_frame_tag("drawer")), + turbo_stream.replace( + "modal", + render_to_string(partial: "trade_republic_items/connection_success", formats: [ :html ]) + ) + ] + end + + flash.now[:alert] = alert if alert + flash.now[:notice] = notice if notice + render turbo_stream: [ + turbo_stream.replace( + "trade-republic-providers-panel", + partial: "settings/providers/trade_republic_panel", + locals: { trade_republic_item: @trade_republic_item } + ), + *flash_notification_stream_items + ] + end + + def turbo_panel_request? + turbo_frame_request? || request.format.turbo_stream? + end + + def render_panel_error(message) + @error_message = message + + if turbo_panel_request? + render turbo_stream: turbo_stream.replace( + "trade-republic-providers-panel", + partial: "settings/providers/trade_republic_panel", + locals: { error_message: @error_message, trade_republic_item: @trade_republic_item } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path(anchor: "trade-republic"), alert: @error_message, status: :see_other + end + end + + def render_qr_login_error(error, status: :unprocessable_entity, retryable: false) + render json: { error: error.message, retryable: retryable }, status: status + end +end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 2dadf0f46..9580d52f0 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -104,6 +104,9 @@ module SettingsHelper when "ibkr" return { status: :off } unless @ibkr_items&.any? sync_based_summary(key) + when "trade_republic" + return { status: :off } unless @trade_republic_items&.any? + sync_based_summary(key) when "indexa_capital" return { status: :off } unless @indexa_capital_items&.any? sync_based_summary(key) diff --git a/app/javascript/controllers/trade_republic_login_controller.js b/app/javascript/controllers/trade_republic_login_controller.js new file mode 100644 index 000000000..006c7f10e --- /dev/null +++ b/app/javascript/controllers/trade_republic_login_controller.js @@ -0,0 +1,77 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static values = { + url: String, + interval: { type: Number, default: 1000 }, + maxRetryDelay: { type: Number, default: 8000 }, + // Give the server a short grace period to recognize the expired login and + // replace the stale waiting state with the retry action. + timeout: { type: Number, default: 125000 }, + }; + + connect() { + this.startedAt = Date.now(); + this.stopped = false; + this.retryCount = 0; + this.schedulePoll(0); + } + + disconnect() { + this.stopped = true; + clearTimeout(this.timer); + } + + schedulePoll(delay) { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.poll(), delay); + } + + async poll() { + if (this.stopped || this.polling) return; + this.polling = true; + + const csrfToken = document.querySelector( + "meta[name='csrf-token']", + )?.content; + try { + const response = await fetch(this.urlValue, { + method: "POST", + headers: { + Accept: "text/vnd.turbo-stream.html", + "X-CSRF-Token": csrfToken || "", + "X-Requested-With": "XMLHttpRequest", + }, + credentials: "same-origin", + body: new URLSearchParams({ authenticity_token: csrfToken || "" }), + }); + + if (response.ok) { + this.retryCount = 0; + Turbo.renderStreamMessage(await response.text()); + } else { + this.retryCount += 1; + console.warn( + `[Trade Republic] login poll failed with HTTP ${response.status}`, + ); + } + } catch (error) { + this.retryCount += 1; + console.warn("[Trade Republic] login poll request failed", error); + } finally { + this.polling = false; + if (!this.stopped && Date.now() - this.startedAt < this.timeoutValue) { + this.schedulePoll( + this.retryCount > 0 ? this.retryDelay() : this.intervalValue, + ); + } + } + } + + retryDelay() { + return Math.min( + this.intervalValue * 2 ** Math.min(this.retryCount - 1, 4), + this.maxRetryDelayValue, + ); + } +} diff --git a/app/javascript/controllers/trade_republic_qr_controller.js b/app/javascript/controllers/trade_republic_qr_controller.js new file mode 100644 index 000000000..471fcc835 --- /dev/null +++ b/app/javascript/controllers/trade_republic_qr_controller.js @@ -0,0 +1,238 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["button", "label", "panel", "code", "status"]; + static values = { + initiateUrl: String, + pollUrl: String, + cancelUrl: String, + returnUrl: String, + autoPoll: { type: Boolean, default: false }, + loadingText: String, + instructionText: String, + successText: String, + errorText: String, + interval: { type: Number, default: 1000 }, + timeout: { type: Number, default: 120000 }, + maxRetryDelay: { type: Number, default: 8000 }, + loginText: String, + cancelText: String, + }; + + connect() { + this.polling = false; + this.stopped = false; + this.pollRequest = null; + this.retryCount = 0; + if (this.autoPollValue) { + this.polling = true; + this.startedAt = Date.now(); + this.panelTarget.hidden = false; + this.setButtonLabel(this.cancelTextValue); + this.statusTarget.textContent = this.instructionTextValue; + this.poll(); + } + } + + disconnect() { + this.stopped = true; + clearTimeout(this.timer); + this.pollRequest?.abort(); + } + + async start(event) { + event.preventDefault(); + if (this.polling) return; + + this.stopped = false; + this.polling = true; + this.retryCount = 0; + this.startedAt = Date.now(); + this.panelTarget.hidden = false; + this.setButtonLabel(this.cancelTextValue); + this.codeTarget.replaceChildren(); + this.statusTarget.textContent = this.loadingTextValue; + + try { + const response = await fetch(this.initiateUrlValue, { + method: "POST", + headers: { ...this.headers(), Accept: "text/vnd.turbo-stream.html" }, + credentials: "same-origin", + body: this.body(), + }); + if (!response.ok) + throw new Error(`QR login initiation failed: ${response.status}`); + + const result = await response.json(); + this.renderQr(result); + + await this.poll(); + } catch (error) { + this.showError(error); + } + } + + async poll() { + if (this.stopped) return; + + try { + this.pollRequest?.abort(); + this.pollRequest = new AbortController(); + const response = await fetch(this.pollUrlValue, { + method: "POST", + headers: { ...this.headers(), Accept: "application/json" }, + credentials: "same-origin", + body: this.body(), + signal: this.pollRequest.signal, + }); + const result = await this.jsonResponse(response); + if (!response.ok) { + const error = new Error(result.error || "QR login failed"); + error.retryable = + result.retryable === true || this.retryableStatus(response.status); + throw error; + } + + this.retryCount = 0; + this.renderQr(result); + + if (result.status !== "pending") { + this.statusTarget.textContent = this.successTextValue; + window.Turbo.visit(this.returnUrlValue); + return; + } + + this.schedulePoll(this.nextPollDelay(result)); + } catch (error) { + if (this.stopped || error.name === "AbortError") return; + + if ( + this.isRetryableError(error) && + Date.now() - this.startedAt < this.timeoutValue + ) { + this.retryCount += 1; + this.schedulePoll(this.retryDelay()); + } else { + this.showError(error); + } + } finally { + this.pollRequest = null; + } + } + + schedulePoll(delay) { + clearTimeout(this.timer); + if (this.stopped) return; + + if (Date.now() - this.startedAt >= this.timeoutValue) { + this.showError(new Error("QR login expired")); + return; + } + + this.timer = setTimeout(() => this.poll(), delay); + } + + retryDelay() { + return Math.min( + this.intervalValue * 2 ** Math.min(this.retryCount - 1, 4), + this.maxRetryDelayValue, + ); + } + + retryableStatus(status) { + return status === 408 || status === 425 || status === 429 || status >= 500; + } + + isRetryableError(error) { + return error.retryable === true || error.name === "TypeError"; + } + + async jsonResponse(response) { + try { + return await response.json(); + } catch { + const error = new Error( + `QR login returned invalid JSON: ${response.status}`, + ); + error.retryable = this.retryableStatus(response.status); + throw error; + } + } + + showError(error) { + if (this.stopped) return; + console.warn("[Trade Republic] QR login failed", error); + this.polling = false; + this.setButtonLabel(this.loginTextValue); + this.buttonTarget.disabled = false; + this.statusTarget.textContent = this.errorTextValue; + } + + toggle(event) { + event.preventDefault(); + if (this.polling) { + this.hideQr(); + } else { + this.start(event); + } + } + + async hideQr() { + this.stopped = true; + clearTimeout(this.timer); + this.pollRequest?.abort(); + this.polling = false; + this.panelTarget.hidden = true; + this.codeTarget.replaceChildren(); + this.buttonTarget.disabled = false; + + try { + const response = await fetch(this.cancelUrlValue, { + method: "POST", + headers: this.headers(), + credentials: "same-origin", + body: this.body(), + }); + if (!response.ok) + throw new Error(`QR login cancellation failed: ${response.status}`); + + Turbo.renderStreamMessage(await response.text()); + } catch (error) { + this.stopped = false; + console.warn("[Trade Republic] QR login cancellation failed", error); + } + } + + renderQr(result) { + if (!result.qr_code_svg) return; + this.codeTarget.innerHTML = result.qr_code_svg; + this.statusTarget.textContent = this.instructionTextValue; + } + + nextPollDelay(result) { + const expiresAt = result.qr_code_token_expires_at; + if (!expiresAt) return this.intervalValue; + + const remaining = Date.parse(expiresAt) - Date.now(); + return remaining > 0 && remaining <= 1500 ? 100 : this.intervalValue; + } + + setButtonLabel(label) { + if (this.hasLabelTarget) this.labelTarget.textContent = label; + } + + headers() { + return { + Accept: "application/json", + "X-CSRF-Token": + document.querySelector("meta[name='csrf-token']")?.content || "", + "X-Requested-With": "XMLHttpRequest", + }; + } + + body() { + const csrfToken = + document.querySelector("meta[name='csrf-token']")?.content || ""; + return new URLSearchParams({ authenticity_token: csrfToken }); + } +} diff --git a/app/jobs/trade_republic_repair_job.rb b/app/jobs/trade_republic_repair_job.rb new file mode 100644 index 000000000..e85dfacd6 --- /dev/null +++ b/app/jobs/trade_republic_repair_job.rb @@ -0,0 +1,24 @@ +class TradeRepublicRepairJob < ApplicationJob + queue_as :low_priority + + def perform(trade_republic_item) + trade_republic_item.trade_republic_accounts.includes(account_provider: :account).each do |provider_account| + next unless provider_account.current_account.present? + + begin + TradeRepublicAccount::Processor.new(provider_account).process + rescue => e + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "TradeRepublicRepairJob - Failed to repair account #{provider_account.id}: #{e.message}", + source: "trade_republic", + family: trade_republic_item.family, + provider_key: "trade_republic", + account_provider_id: provider_account.account_provider&.id, + metadata: { trade_republic_account_id: provider_account.id } + ) + end + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index ffbc23ce2..b70858d20 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -396,6 +396,25 @@ class Account < ApplicationRecord create_and_sync(attributes, skip_initial_sync: true) end + def create_from_trade_republic_account(trade_republic_account) + family = trade_republic_account.trade_republic_item.family + is_cash = trade_republic_account.cash? + + attributes = { + family: family, + name: trade_republic_account.name.presence || (is_cash ? "Trade Republic Cash" : "Trade Republic Portfolio"), + balance: 0, + cash_balance: 0, + currency: trade_republic_account.currency.presence || family.currency, + accountable_type: is_cash ? "Depository" : "Investment", + accountable_attributes: { + subtype: is_cash ? "checking" : "brokerage" + } + } + + create_and_sync(attributes, skip_initial_sync: true) + end + def create_from_kraken_account(kraken_account) create_from_crypto_exchange_account(kraken_account, family: kraken_account.kraken_item.family) end diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index ae29d80dd..6af743c14 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -18,6 +18,7 @@ class DataEnrichment < ApplicationRecord sophtron: "sophtron", ibkr: "ibkr", questrade: "questrade", - redbark: "redbark" + redbark: "redbark", + trade_republic: "trade_republic" } end diff --git a/app/models/family.rb b/app/models/family.rb index f12d548ee..f4a3ff765 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -5,6 +5,7 @@ class Family < ApplicationRecord include IndexaCapitalConnectable, IbkrConnectable, WiseConnectable include UpConnectable include Trading212Connectable + include TradeRepublicConnectable include QuestradeConnectable include RedbarkConnectable include OnchainWalletConnectable diff --git a/app/models/family/trade_republic_connectable.rb b/app/models/family/trade_republic_connectable.rb new file mode 100644 index 000000000..687f2ec41 --- /dev/null +++ b/app/models/family/trade_republic_connectable.rb @@ -0,0 +1,11 @@ +module Family::TradeRepublicConnectable + extend ActiveSupport::Concern + + included do + has_many :trade_republic_items, dependent: :destroy + end + + def can_connect_trade_republic? + true + end +end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb index 4baf2a4ab..9289a09c9 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -18,6 +18,7 @@ class Provider indexa_capital: { region: "ES", kinds: %w[Investment], maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" }, sophtron: { region: "US", kinds: %w[Bank Investment], maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" }, trading212: { region: "EU", kinds: %w[Investment], maturity: :alpha, logo_text: "T2", logo_bg: "bg-teal-600" }, + trade_republic: { region: "EU", kinds: %w[Bank Investment], maturity: :beta, logo_text: "TR", logo_bg: "bg-primary" }, plaid: { region: "US", kinds: %w[Bank], maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600", tier: "Paid" }, plaid_eu: { region: "EU", kinds: %w[Bank], maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600", tier: "Paid", name: "Plaid EU" }, questrade: { region: "CA", kinds: %w[Investment], maturity: :beta, logo_text: "QT", logo_bg: "bg-teal-600" }, diff --git a/app/models/provider/trade_republic_adapter.rb b/app/models/provider/trade_republic_adapter.rb new file mode 100644 index 000000000..662edd879 --- /dev/null +++ b/app/models/provider/trade_republic_adapter.rb @@ -0,0 +1,59 @@ +class Provider::TradeRepublicAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + Provider::Factory.register("TradeRepublicAccount", self) + + def self.supported_account_types + %w[Depository Investment] + end + + def self.connection_configs(family:) + return [] unless family.can_connect_trade_republic? + + [ { + key: "trade_republic", + name: I18n.t("providers.trade_republic.name"), + description: I18n.t("providers.trade_republic.connection_description"), + can_connect: true, + new_account_path: ->(_accountable_type, _return_to) { + Rails.application.routes.url_helpers.select_accounts_trade_republic_items_path + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_trade_republic_items_path(account_id: account_id) + } + } ] + end + + def provider_name + "trade_republic" + end + + def sync_path + Rails.application.routes.url_helpers.sync_trade_republic_item_path(item) + end + + def item + provider_account.trade_republic_item + end + + def can_delete_holdings? + true + end + + def institution_domain + "traderepublic.com" + end + + def institution_name + I18n.t("providers.trade_republic.institution_name") + end + + def institution_url + "https://www.traderepublic.com" + end + + def institution_color + "#1C1C1C" + end +end diff --git a/app/models/provider/trade_republic_client.rb b/app/models/provider/trade_republic_client.rb new file mode 100644 index 000000000..f98773f0c --- /dev/null +++ b/app/models/provider/trade_republic_client.rb @@ -0,0 +1,859 @@ +require "base64" +require "cgi" +require "json" +require "bigdecimal" +require "set" +require "time" + +class Provider::TradeRepublicClient + class Error < StandardError; end + class ConfigurationError < Error; end + class AuthenticationRequired < Error; end + class LoginExpired < Error; end + class InvalidChallenge < Error; end + class ProviderUnavailable < Error; end + class TransientProviderError < ProviderUnavailable; end + class RateLimited < Error + attr_reader :retry_after + + def initialize(message = nil, retry_after: nil) + @retry_after = retry_after + super(message) + end + end + class WafRequired < Error; end + class Timeout < Error; end + class MalformedResponse < Error; end + + ERROR_STATUS = { + 401 => AuthenticationRequired, + 403 => AuthenticationRequired, + 408 => Timeout, + 429 => RateLimited, + 500 => TransientProviderError, + 502 => TransientProviderError, + 503 => TransientProviderError, + 504 => TransientProviderError + }.freeze + DETAIL_CATEGORIES = %w[orderExecution PAYMENT_RECEIVED POC_CREATED INTEREST_PAYOUT_CREATED DIVIDEND].freeze + EVENT_TYPE_CATEGORIES = { + "TRADING_TRADE_EXECUTED" => "orderExecution", + "TRADE_INVOICE" => "orderExecution", + "ORDER_EXECUTED" => "orderExecution", + "CRYPTO_INVOICE" => "orderExecution", + "SAVINGS_PLAN_EXECUTED" => "orderExecution", + "TRADING_SAVINGSPLAN_EXECUTED" => "orderExecution", + "PRIVATE_MARKET_FUND_TRADE_EXECUTED" => "orderExecution", + "IPO_TRADE_EXECUTED" => "orderExecution", + "BANK_TRANSACTION_INCOMING" => "PAYMENT_RECEIVED", + "INCOMING_TRANSFER" => "PAYMENT_RECEIVED", + "INCOMING_TRANSFER_DELEGATION" => "PAYMENT_RECEIVED", + "PAYMENT_INBOUND" => "PAYMENT_RECEIVED", + "PAYMENT_INBOUND_SEPA_DIRECT_DEBIT" => "PAYMENT_RECEIVED", + "PAYMENT_INBOUND_APPLE_PAY" => "PAYMENT_RECEIVED", + "BANK_TRANSACTION_OUTGOING" => "POC_CREATED", + "BANK_TRANSACTION_OUTGOING_DIRECT_DEBIT" => "POC_CREATED", + "OUTGOING_TRANSFER" => "POC_CREATED", + "OUTGOING_TRANSFER_DELEGATION" => "POC_CREATED", + "PAYMENT_OUTBOUND" => "POC_CREATED", + "CARD_TRANSACTION" => "POC_CREATED", + "card_successful_transaction" => "POC_CREATED", + # Trade Republic currently uses CARD_CASH_BACK for some card purchases + # (for example, Marktkauf), not only for actual cashback credits. The + # signed provider amount confirms these are cash outflows. + "CARD_CASH_BACK" => "POC_CREATED", + "card_refund" => "PAYMENT_RECEIVED", + "CARD_REFUND" => "PAYMENT_RECEIVED", + "SPARE_CHANGE_AGGREGATE" => "POC_CREATED", + "SAVEBACK_AGGREGATE" => "POC_CREATED", + "BANK_TRANSACTION_OUTGOING_SCHEDULED" => "POC_CREATED", + "CARD_ATM_WITHDRAWAL" => "POC_CREATED", + "SSP_CORPORATE_ACTION_CASH" => "DIVIDEND", + "ssp_corporate_action_invoice_cash" => "DIVIDEND", + "SSP_CORPORATE_ACTION_CASH_NON_DIVIDEND" => "PAYMENT_RECEIVED", + "DIVIDEND" => "DIVIDEND", + "CREDIT" => "DIVIDEND", + "INTEREST_PAYOUT" => "INTEREST_PAYOUT_CREATED", + "INTEREST_PAYOUT_CREATED" => "INTEREST_PAYOUT_CREATED", + "TAX_REFUND" => "PAYMENT_RECEIVED", + "ssp_tax_correction_invoice" => "PAYMENT_RECEIVED", + "SSP_TAX_CORRECTION" => "PAYMENT_RECEIVED", + "CARD_ORDER_FEE" => "POC_CREATED" + }.freeze + PORTFOLIO_CATEGORIES = { + "stocksAndETFs" => "brokerage", + "privateMarkets" => "private_markets", + "interest" => "interest_products", + "bonds" => "interest_products", + "cryptos" => "crypto_wallet" + }.freeze + TICKER_EXCHANGES = %w[LSX BHS TUB SGL BVT].freeze + CRYPTO_TICKER_EXCHANGES = %w[BHS TUB SGL BVT LSX].freeze + FEE_TITLES = [ "gebühr", "fee" ].freeze + TAX_TITLES = [ "steuer", "steuern", "tax", "taxes" ].freeze + MAX_TIMELINE_PAGES = 50 + MAX_TIMELINE_DETAILS = 200 + MAX_SYNC_RETRIES = 2 + RETRY_BACKOFF_SECONDS = 0.5 + LOGIN_SUCCESS_STATES = %w[CONFIRMED COMPLETED APPROVED SUCCESS OK DONE].freeze + CONNECT_MESSAGE = { locale: "en", platformId: "webtrading", platformVersion: "chrome - 94.0.4606", clientId: "app.traderepublic.com", clientVersion: "5582" }.freeze + + Result = Struct.new(:data, keyword_init: true) do + def [](key) = data[key] + end + + attr_reader :phone_number, :pin + + def initialize(phone_number:, pin: nil) + @phone_number = phone_number.to_s.strip + @pin = pin.to_s + end + + def initiate_login + require_pin! + session = new_session + response = session.post("/api/v2/auth/web/login", body: { phoneNumber: phone_number, pin: pin }, headers: session.login_headers) + raise_http_error(response) + body = parse_json(response) + process_id = body["processId"].presence + raise MalformedResponse, "Trade Republic login response did not contain a process ID" if process_id.blank? + + process_response = session.get("/api/v2/auth/web/login/processes/#{escape_path(process_id)}", headers: session.login_headers) + raise_http_error(process_response, login: true) + required_action = parse_json(process_response)["requiredAction"] + countdown_seconds = body.fetch("countdownInSeconds", 120).to_i.clamp(1, 120) + pending = { + "process_id" => process_id, + "required_action" => required_action, + "session_blob" => session.cookies_blob, + "expires_at" => countdown_seconds.seconds.from_now.iso8601 + } + + Result.new(data: { + "status" => "verification_required", + "method" => required_action == "AUTHENTICATOR_VERIFICATION" ? "authenticator" : "push", + "countdown_seconds" => countdown_seconds, + "pending_login_b64" => Base64.strict_encode64(JSON.generate(pending)) + }) + rescue Net::HTTPClientException => e + raise_login_error(e.response) + end + + # Starts the browser QR login without blocking while the user scans and + # approves the challenge in the Trade Republic app. + def initiate_qr_login + session = new_session + response = session.post("/api/v2/auth/web/login/qr-challenges", body: nil, headers: session.login_headers) + raise_http_error(response, login: true) + body = parse_json(response) + challenge_id = body["challengeId"].presence + raise MalformedResponse, "Trade Republic QR login response did not contain a challenge ID" if challenge_id.blank? + + pending = { + "challenge_id" => challenge_id, + "session_blob" => session.cookies_blob, + "expires_at" => body["challengeExpiresAt"].presence || 120.seconds.from_now.iso8601, + "qr_code_payload" => body["qrCodePayload"].presence, + "qr_code_token_expires_at" => body["qrCodeTokenExpiresAt"].presence + } + Result.new(data: { + "status" => "qr_pending", + "pending_login_b64" => encode_pending(pending), + "expires_at" => pending["expires_at"], + "qr_code_payload" => pending["qr_code_payload"], + "qr_code_token_expires_at" => pending["qr_code_token_expires_at"] + }) + rescue Net::HTTPClientException => e + raise_login_error(e.response) + end + + def poll_qr_login(pending_login_b64:) + pending = decode_qr_pending(pending_login_b64) + session = new_session(session_blob: pending.fetch("session_blob")) + challenge_response = session.get( + "/api/v2/auth/web/login/qr-challenges/#{escape_path(pending.fetch("challenge_id"))}", + headers: session.login_headers + ) + raise_http_error(challenge_response, login: true) + challenge = parse_json(challenge_response) + process_id = challenge["processId"].presence + + if process_id.blank? && login_process_completed?(challenge) + return authenticated_session_result(session) + end + + unless process_id + qr_code_payload = challenge["qrCodePayload"].presence || pending["qr_code_payload"] + next_pending = pending.merge( + "qr_code_payload" => qr_code_payload, + "qr_code_token_expires_at" => challenge["qrCodeTokenExpiresAt"].presence || pending["qr_code_token_expires_at"] + ) + return Result.new(data: { + "status" => "pending", + "qr_code_payload" => qr_code_payload, + "qr_code_token_expires_at" => next_pending["qr_code_token_expires_at"], + "pending_login_b64" => encode_pending(next_pending) + }.compact) + end + + process_response = session.get( + "/api/v2/auth/web/login/processes/#{escape_path(process_id)}", + headers: session.login_headers + ) + raise_http_error(process_response, login: true) + process = parse_json(process_response) + unless login_process_completed?(process) + return Result.new(data: { + "status" => "pending", + "process_id" => process_id, + "pending_login_b64" => encode_pending(pending.merge("process_id" => process_id)) + }) + end + + authenticated_session_result(session) + rescue Net::HTTPClientException => e + raise_login_error(e.response) + end + + def complete_login(pending_login_b64:, code: nil) + pending = decode_pending(pending_login_b64) + session = new_session(session_blob: pending.fetch("session_blob")) + process_id = pending.fetch("process_id") + headers = session.login_headers + + if pending["required_action"] == "AUTHENTICATOR_VERIFICATION" && !pending["authenticator_verified"] + raise InvalidChallenge, "Authenticator code is required" if code.blank? + response = session.post("/api/v2/auth/web/login/processes/#{escape_path(process_id)}/authenticator-verification", body: { code: code }, headers: headers) + raise_http_error(response, login: true) + pending["authenticator_verified"] = true + end + + process_response = session.get("/api/v2/auth/web/login/processes/#{escape_path(process_id)}", headers: headers) + raise_http_error(process_response, login: true) + process = parse_json(process_response) + unless login_process_completed?(process) + return Result.new(data: { + "status" => "pending", + "pending_login_b64" => Base64.strict_encode64(JSON.generate(pending)) + }) + end + + authenticated_session_result(session) + rescue KeyError, ArgumentError => e + raise InvalidChallenge, "Trade Republic login state is invalid: #{e.message}" + rescue Net::HTTPClientException => e + raise_login_error(e.response) + end + + def login_method(pending_login_b64:) + pending = decode_pending(pending_login_b64) + pending["required_action"] == "AUTHENTICATOR_VERIFICATION" ? "authenticator" : "push" + end + + def qr_login?(pending_login_b64:) + pending = JSON.parse(Base64.strict_decode64(pending_login_b64.to_s)) + pending["challenge_id"].present? + rescue JSON::ParserError, ArgumentError + false + end + + def login_stage(pending_login_b64:) + return "qr_pending" if qr_login?(pending_login_b64: pending_login_b64) + + pending = decode_pending(pending_login_b64) + if pending["required_action"] == "AUTHENTICATOR_VERIFICATION" && !pending["authenticator_verified"] + "authenticator_code" + else + "waiting_for_approval" + end + end + + def sync(session_txt:, known_newest_event_id: nil, timeline_max_pages: MAX_TIMELINE_PAGES) + raise ConfigurationError, "session_txt is required" if session_txt.blank? + + with_retry do + sync_once( + session_txt: session_txt, + known_newest_event_id: known_newest_event_id, + timeline_max_pages: timeline_max_pages + ) + end + end + + def sync_once(session_txt:, known_newest_event_id:, timeline_max_pages:) + session = new_session(session_blob: session_txt) + account_response = session.get("/api/v2/auth/account") + return Result.new(data: { "status" => "session_expired" }) if [ 401, 403 ].include?(account_response.code.to_i) + raise_http_error(account_response) + account = parse_json(account_response) + raise MalformedResponse, "Trade Republic account response did not contain a securities account number" if account["securitiesAccountNumber"].blank? + warnings = [] + domain_statuses = { + "account_metadata" => "success", + "cash" => "failed", + "portfolio" => "failed", + "timeline" => "failed", + "instrument_metadata" => "failed" + } + websocket = Provider::TradeRepublicWebsocket.new(headers: session.websocket_headers).connect + + begin + websocket.send_text("connect 31 #{JSON.generate(CONNECT_MESSAGE)}") + connected = websocket.receive + raise TransientProviderError, "Trade Republic WebSocket handshake was rejected" unless connected == "connected" + + cash = available_cash = nil + begin + cash = subscribe(websocket, type: "cash") + available_cash = optional_subscribe(websocket, type: "availableCash") + raise MalformedResponse, "Trade Republic cash response did not contain an amount" if money_amount(cash).nil? + domain_statuses["cash"] = "success" + rescue MalformedResponse, ProviderUnavailable => e + raise if e.is_a?(TransientProviderError) + warnings << "cash fetch failed: #{e.message}" + end + + positions = [] + position_warnings = [] + begin + portfolio = subscribe(websocket, type: "compactPortfolioByType", secAccNo: account["securitiesAccountNumber"]) + raise MalformedResponse, "Trade Republic portfolio response did not contain categories" unless portfolio.is_a?(Hash) && portfolio.key?("categories") + positions, position_warnings = normalize_positions(websocket, portfolio) + warnings.concat(position_warnings) + domain_statuses["portfolio"] = "success" + domain_statuses["instrument_metadata"] = position_warnings.empty? ? "success" : "partial" + rescue MalformedResponse, ProviderUnavailable => e + raise if e.is_a?(TransientProviderError) + warnings << "portfolio fetch failed: #{e.message}" + end + + events = [] + newest_event_id = nil + timeline_warnings = [] + timeline_complete = false + begin + events, newest_event_id, timeline_warnings, timeline_complete = collect_all_timeline( + websocket, + known_newest_event_id: known_newest_event_id, + max_pages: timeline_max_pages.to_i + ) + warnings.concat(timeline_warnings) + domain_statuses["timeline"] = timeline_complete ? "success" : "partial" + rescue MalformedResponse, ProviderUnavailable => e + raise if e.is_a?(TransientProviderError) + warnings << "timeline fetch failed: #{e.message}" + end + + Result.new(data: { + "status" => domain_statuses.values.all? { |status| status == "success" } ? "ok" : "partial", + "session_txt" => session.cookies_blob, + "domain_statuses" => domain_statuses, + "account" => { "brokerage_account_id" => account["securitiesAccountNumber"].to_s, "currency" => account["currency"] }, + "cash" => (cash && { + "amount" => decimal_string(money_amount(cash)), + "available_amount" => decimal_string(money_amount(available_cash)), + "currency" => money_currency(available_cash) || money_currency(cash) + }.compact), + "positions" => positions, "events" => events, "newest_event_id" => newest_event_id, + "warnings" => warnings, "position_warnings" => position_warnings + }) + ensure + websocket.close + end + rescue Provider::TradeRepublicClient::Timeout + raise Timeout, "Trade Republic WebSocket timed out" + end + + class << self + def available? = !!defined?(WebSocket::Driver) + end + + private + + def with_retry + attempts = 0 + begin + attempts += 1 + yield + rescue Timeout, RateLimited, TransientProviderError => e + raise if attempts > MAX_SYNC_RETRIES + + delay = if e.is_a?(RateLimited) && e.retry_after.present? + e.retry_after + else + RETRY_BACKOFF_SECONDS * (2**(attempts - 1)) + end + sleep_for([ delay.to_f, 30.0 ].min) + retry + end + end + + def sleep_for(seconds) + sleep(seconds) + end + + def new_session(session_blob: nil) + Provider::TradeRepublicSession.new(phone_number: phone_number, pin: pin, session_blob: session_blob) + end + + def require_pin! + raise ConfigurationError, "Trade Republic PIN is required for authentication" if pin.blank? + end + + def decode_pending(value) + pending = JSON.parse(Base64.strict_decode64(value.to_s)) + raise ArgumentError, "missing login process" unless pending["process_id"].present? + raise ArgumentError, "missing login session" unless pending["session_blob"].present? + raise LoginExpired, "Trade Republic login process expired" if pending["expires_at"].present? && Time.iso8601(pending["expires_at"]) <= Time.current + pending + rescue JSON::ParserError, ArgumentError => e + raise InvalidChallenge, "Trade Republic login state is unreadable: #{e.message}" + end + + def decode_qr_pending(value) + pending = JSON.parse(Base64.strict_decode64(value.to_s)) + raise ArgumentError, "missing QR challenge" unless pending["challenge_id"].present? + raise ArgumentError, "missing login session" unless pending["session_blob"].present? + expires_at = pending["expires_at"].presence + raise LoginExpired, "Trade Republic QR login expired" if expires_at && Time.iso8601(expires_at) <= Time.current + pending + rescue JSON::ParserError, ArgumentError => e + raise InvalidChallenge, "Trade Republic QR login state is unreadable: #{e.message}" + end + + def encode_pending(pending) + Base64.strict_encode64(JSON.generate(pending)) + end + + def login_process_completed?(process) + %w[state status statusCode result].any? do |key| + LOGIN_SUCCESS_STATES.include?(process[key].to_s.upcase) + end + end + + def authenticated_session_result(session) + account_response = session.get("/api/v2/auth/account", headers: session.login_headers) + raise_http_error(account_response, login: true) + account = parse_json(account_response) + if account["securitiesAccountNumber"].blank? + raise MalformedResponse, "Trade Republic account response did not contain a securities account number" + end + + Result.new(data: { + "status" => "ok", + "session_txt" => session.cookies_blob, + "account" => { + "brokerage_account_id" => account["securitiesAccountNumber"].to_s, + "currency" => account["currency"] + } + }) + end + + def escape_path(value) = CGI.escape(value.to_s).tr("+", "%20") + + def parse_json(response) + JSON.parse(response.body.to_s) + rescue JSON::ParserError => e + raise MalformedResponse, "Trade Republic returned invalid JSON: #{e.message}" + end + + def raise_http_error(response, login: false) + return if response.is_a?(Net::HTTPSuccess) + error_code = response_error_code(response) + if error_code.to_s.match?(/WAF|MISSING_REQUIRED_HEADER/) + raise WafRequired, "Trade Republic requires an AWS WAF browser token" + end + raise_login_error(response) if login + message = "Trade Republic request failed with HTTP #{response.code}" + message += " (#{error_code})" if error_code.present? + error_class = ERROR_STATUS.fetch(response.code.to_i, ProviderUnavailable) + if error_class == RateLimited + raise RateLimited.new(message, retry_after: retry_after_seconds(response)) + end + + raise error_class, message + end + + def retry_after_seconds(response) + value = response["Retry-After"].to_s + return value.to_f if value.match?(/\A\d+(?:\.\d+)?\z/) + + return if value.blank? + + [ Time.httpdate(value) - Time.current, 0 ].max + rescue ArgumentError + nil + end + + def raise_login_error(response) + return if response.is_a?(Net::HTTPSuccess) + code = begin + response_error_code(response) + rescue MalformedResponse + nil + end + raise LoginExpired, "Trade Republic login process expired" if response.code.to_i == 404 + if response.code.to_i == 409 && code.to_s == "ALREADY_PROCESSED" + raise LoginExpired, "Trade Republic QR login token expired or was already used" + end + raise InvalidChallenge, "Trade Republic rejected the authenticator code" if code.to_s.match?(/CODE|AUTHENTICATOR|VERIFICATION/) + raise ERROR_STATUS.fetch(response.code.to_i, ProviderUnavailable), "Trade Republic login failed" + end + + def response_error_code(response) + body = parse_json(response) + body["errorCode"].presence || body.dig("errors", 0, "errorCode").presence + rescue MalformedResponse + nil + end + + def subscribe(websocket, payload) + @subscription_id = @subscription_id.to_i + 1 + websocket.send_text("sub #{@subscription_id} #{JSON.generate(payload)}") + receive_subscription(websocket, @subscription_id) + ensure + begin + websocket.send_text("unsub #{@subscription_id}") if @subscription_id + rescue IOError, ProviderUnavailable + nil + end + end + + def optional_subscribe(websocket, payload) + subscribe(websocket, payload) + rescue TransientProviderError + raise + rescue Error + nil + end + + def receive_subscription(websocket, subscription_id) + previous = nil + loop do + message = websocket.receive.to_s + id, code, payload = message.split(" ", 3) + next unless id.to_s == subscription_id.to_s + case code + when "A" + previous = payload.to_s + return parse_payload(previous) + when "D" + previous = apply_delta(previous, payload.to_s) + return parse_payload(previous) + when "E" then raise ProviderUnavailable, "Trade Republic subscription failed" + when "C" then raise ProviderUnavailable, "Trade Republic closed the subscription" + end + end + end + + def parse_payload(payload) + JSON.parse(payload.presence || "{}") + rescue JSON::ParserError => e + raise MalformedResponse, "Trade Republic WebSocket payload is invalid: #{e.message}" + end + + def apply_delta(previous, delta) + raise MalformedResponse, "Trade Republic sent a delta without a base response" if previous.blank? + index = 0 + delta.split("\t").filter_map do |diff| + sign = diff[0] + case sign + when "+" then CGI.unescape(diff).strip + when "=" + length = diff[1..].to_i + fragment = previous[index, length] + index += length + fragment + when "-" + index += diff[1..].to_i + nil + end + end.join + end + + def normalize_positions(websocket, portfolio) + raw_positions = Array(portfolio["categories"]).flat_map do |category| + Array(category["positions"]).map { |position| position.merge("categoryType" => category["categoryType"]) } + end + warnings = [] + valid_positions = raw_positions.select do |position| + isin = position["instrumentId"].presence || position["isin"] + quantity = position["netSize"] || position["quantity"] + if isin.blank? || quantity.blank? + warnings << "malformed portfolio position skipped: missing #{isin.blank? ? "instrument ID" : "quantity"}" + false + else + true + end + end + prices = {} + valid_positions.each do |position| + isin = position["instrumentId"].presence || position["isin"] + next if prices.key?(isin) + + price = position_price(websocket, isin, position["categoryType"]) + prices[isin] = price if price.present? + warnings << "price unavailable for #{isin}; position kept without valuation" if price.blank? + end + + positions = valid_positions.map do |position| + isin = position["instrumentId"].presence || position["isin"] + quantity = position["netSize"] || position["quantity"] + { "isin" => isin, "name" => position["name"], "category" => portfolio_category(position["categoryType"]), "quantity" => decimal_string(quantity), "average_cost" => decimal_string(position["averageBuyIn"] || position["avgCost"]), "price" => prices[isin] }.compact + end + [ positions, warnings ] + end + + def position_price(websocket, isin, category_type) + exchanges = category_type.to_s == "cryptos" ? CRYPTO_TICKER_EXCHANGES : TICKER_EXCHANGES + exchanges.each do |exchange| + ticker = subscribe(websocket, type: "ticker", id: "#{isin}.#{exchange}") + price = ticker.dig("last", "price") if ticker.is_a?(Hash) + return decimal_string(price) if price.present? + rescue Timeout + # A ticker that never answers is instrument-metadata loss, not a + # failed portfolio snapshot. Keep the holding and let the importer + # record the missing valuation instead of retrying the whole sync. + return nil + rescue TransientProviderError, RateLimited + raise + rescue Error + next + end + + nil + end + + def portfolio_category(category_type) + PORTFOLIO_CATEGORIES[category_type.to_s] || category_type + end + + def money_amount(value) + case value + when Hash + direct = value["amount"] || value["value"] || value["balance"] || value["available"] + return direct if direct.is_a?(Numeric) || direct.to_s.match?(/\A-?[\d.,]+\z/) + + value.each_value do |child| + amount = money_amount(child) + return amount if amount.present? + end + when Array + value.each do |child| + amount = money_amount(child) + return amount if amount.present? + end + end + nil + end + + def money_currency(value) + case value + when Hash + return value["currency"] if value["currency"].present? + value.each_value do |child| + currency = money_currency(child) + return currency if currency.present? + end + when Array + value.each do |child| + currency = money_currency(child) + return currency if currency.present? + end + end + nil + end + + def collect_timeline(websocket, known_newest_event_id:, max_pages:) + collect_timeline_topic( + websocket, + topic: "timelineTransactions", + known_newest_event_id: known_newest_event_id, + max_pages: max_pages + ) + end + + def collect_all_timeline(websocket, known_newest_event_id:, max_pages:) + transaction_events, transaction_cursor, transaction_warnings, transaction_complete = collect_timeline_topic( + websocket, + topic: "timelineTransactions", + known_newest_event_id: known_newest_event_id, + max_pages: max_pages + ) + activity_events, activity_cursor, activity_warnings, activity_complete = collect_timeline_topic( + websocket, + topic: "timelineActivityLog", + known_newest_event_id: known_newest_event_id, + max_pages: max_pages + ) + events = (transaction_events + activity_events).uniq do |event| + event["id"].presence || event.slice("timestamp", "eventType", "title", "subtitle", "detail") + end + newest_event = events.max_by { |event| event["timestamp"].to_s } + details_incomplete = (transaction_events.any? && transaction_cursor.nil?) || + (activity_events.any? && activity_cursor.nil?) + timeline_complete = transaction_complete != false && activity_complete != false && + !details_incomplete && + (transaction_warnings + activity_warnings).none? { |warning| warning.start_with?("detail fetch failed") } + [ + events, + details_incomplete ? nil : (newest_event&.dig("id") || transaction_cursor || activity_cursor), + transaction_warnings + activity_warnings, + timeline_complete + ] + end + + def collect_timeline_topic(websocket, topic:, known_newest_event_id:, max_pages:) + items = [] + newest_event_id = nil + cursor = nil + warnings = [] + pages = 0 + seen_cursors = Set.new + reached_known_event = false + complete = true + while pages < [ max_pages, MAX_TIMELINE_PAGES ].min + payload = { type: topic } + payload[:after] = cursor if cursor + response = subscribe(websocket, payload) + page_items = response.is_a?(Hash) ? Array(response["items"]) : [] + break if page_items.empty? + page_items.each do |item| + id = item["id"].to_s + reached_known_event ||= known_newest_event_id.present? && id == known_newest_event_id.to_s + items << item + newest_event_id ||= id.presence + end + cursor = response.dig("cursors", "after") + break if cursor.blank? + if reached_known_event + break + end + if seen_cursors.include?(cursor) + warnings << "timeline pagination cursor repeated for #{topic}" + complete = false + break + end + seen_cursors << cursor + pages += 1 + end + if cursor.present? && !reached_known_event && pages >= [ max_pages, MAX_TIMELINE_PAGES ].min + warnings << "timeline pagination truncated for #{topic}" + complete = false + end + details, resolved_newest_event_id, detail_warnings = resolve_details(websocket, items, newest_event_id, warnings) + [ details, resolved_newest_event_id, detail_warnings, complete ] + end + + def resolve_details(websocket, items, newest_event_id, warnings) + events = [] + details_fetched = 0 + details_skipped = false + items.each do |item| + detail = nil + category = item["category"].presence || EVENT_TYPE_CATEGORIES[item["eventType"].to_s] + warnings << "unsupported timeline event type #{item["eventType"]}" if category.blank? + if DETAIL_CATEGORIES.include?(category.to_s) && details_fetched < MAX_TIMELINE_DETAILS + begin + detail = normalize_event_detail( + subscribe(websocket, type: "timelineDetailV2", id: item["id"]), + item: item + ) + details_fetched += 1 + rescue TransientProviderError, Timeout, RateLimited + raise + rescue Error + warnings << "detail fetch failed for event #{item["id"]}" + end + elsif DETAIL_CATEGORIES.include?(category.to_s) + details_skipped = true + end + amount = item.dig("amount", "value") + event_detail = { + "amount" => amount, + "signed_amount" => amount, + "currency" => item.dig("amount", "currency") + }.compact + detail ||= {} + detail = event_detail.merge(detail) if event_detail.present? + events << item.slice("id", "timestamp", "title", "subtitle", "eventType") + .merge("category" => category, "detail" => detail.presence) + end + newest_event_id = nil if details_skipped || events.any? { |event| DETAIL_CATEGORIES.include?(event["category"]) && event["detail"].nil? } + [ events, newest_event_id, warnings ] + end + + def normalize_event_detail(raw, item: nil) + rows = collect_sections(raw).flat_map { |section| Array(section["data"]) }.select { |row| row.is_a?(Hash) } + shares = find_row(rows, [ "aktien", "anteile", "shares", "aktien hinzugefügt", "shares added", "aktien erhalten", "shares received", "aktien entfernt", "shares removed", "aktien gesendet", "shares sent" ]) + total = find_row(rows, [ "gesamt", "total" ]) + fees = find_row(rows, FEE_TITLES) + taxes = find_row(rows, TAX_TITLES) + quantity = decimal_from_row(shares) || quantity_from_raw(raw) + title = shares&.dig("title").to_s.downcase + quantity = -quantity.abs if title.include?("entfernt") || title.include?("removed") || title.include?("gesendet") || title.include?("sent") + quantity = -quantity.abs if quantity && item&.dig("subtitle").to_s.downcase.include?("sell") + amount = decimal_from_row(total) + return nil if quantity.nil? && amount.nil? + { "isin" => find_isin(item) || find_isin(raw), "name" => item&.dig("title") || find_asset_name(raw), "quantity" => decimal_string(quantity), "price" => nil, "amount" => decimal_string(amount&.abs), "currency" => currency_from_row(total) || currency_from_row(shares), "fees" => decimal_string(decimal_from_row(fees)), "taxes" => decimal_string(decimal_from_row(taxes)) }.compact + end + + def collect_sections(node, result = []) + case node + when Hash + result << node if node.key?("title") && node["data"].is_a?(Array) + node.each_value { |value| collect_sections(value, result) } + when Array then node.each { |value| collect_sections(value, result) } + end + result + end + + def find_row(rows, titles) = rows.find { |row| titles.include?(row["title"].to_s.downcase.strip) } + + def decimal_from_row(row) + text = row&.dig("detail", "text") || row&.dig("detail", "value", "text") + return nil if text.blank? + normalized = text.to_s.gsub(/[^\d,.-]/, "") + normalized = normalized.gsub(".", "").tr(",", ".") if normalized.count(",") == 1 && normalized.rindex(",") > normalized.rindex(".") + BigDecimal(normalized) + rescue ArgumentError + nil + end + + def quantity_from_raw(raw) + value = nil + walk(raw) do |node| + next unless node.is_a?(String) + + match = node.match(/\A\s*([\d.,]+)\s*[×x]/) + value ||= BigDecimal(match[1].tr(",", ".")) if match + end + value + rescue ArgumentError + nil + end + + def currency_from_row(row) = row&.dig("detail", "value", "currency") + + def find_isin(raw) + values = [] + walk(raw) { |value| values << value if value.is_a?(String) && value.match?(/\A[A-Z]{2}[A-Z0-9]{9}\d\z/) } + values.first + end + + def find_asset_name(raw) + rows = collect_sections(raw).flat_map { |section| Array(section["data"]) } + row = rows.find { |candidate| %w[wertpapier asset vermögenswert security].include?(candidate["title"].to_s.downcase) } + row&.dig("detail", "text") || row&.dig("detail", "value", "text") + end + + def walk(node, &block) + yield node + case node + when Hash then node.each_value { |value| walk(value, &block) } + when Array then node.each { |value| walk(value, &block) } + end + end + + def decimal_string(value) = value.blank? ? nil : value.to_s.strip +end diff --git a/app/models/provider/trade_republic_session.rb b/app/models/provider/trade_republic_session.rb new file mode 100644 index 000000000..f595cfccd --- /dev/null +++ b/app/models/provider/trade_republic_session.rb @@ -0,0 +1,179 @@ +require "base64" +require "digest" +require "etc" +require "json" +require "net/http" +require "openssl" +require "securerandom" +require "socket" +require "uri" + +class Provider::TradeRepublicSession + API_HOST = "api.traderepublic.com" + API_ORIGIN = "https://#{API_HOST}" + USER_AGENT = ENV.fetch( + "TRADE_REPUBLIC_USER_AGENT", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/151.0.0.0 Safari/537.36" + ) + APP_VERSION = ENV.fetch("TRADE_REPUBLIC_APP_VERSION", "2.2631.13") + WEB_PLATFORM = "web-pro" + + attr_reader :phone_number, :pin + + def initialize(phone_number:, pin:, session_blob: nil) + @phone_number = phone_number.to_s + @pin = pin.to_s + @cookies = decode_cookies(session_blob) + @device_info = nil + @session_expires_at = Time.at(0) + end + + def post(path, body:, headers: {}) + request(Net::HTTP::Post, path, body: body, headers: headers) + end + + def get(path, headers: {}, body: nil) + request(Net::HTTP::Get, path, body: body, headers: headers) + end + + def cookies_blob + JSON.generate(@cookies) + end + + def cookie_header + @cookies.map { |name, value| "#{name}=#{value}" }.join("; ") + end + + def login_headers + browser_headers.merge( + "X-TR-Device-Info" => device_info, + "X-TR-App-Version" => APP_VERSION, + "X-Tr-Platform" => WEB_PLATFORM + ) + end + + def websocket_headers + browser_headers.merge( + "Cookie" => cookie_header, + "Origin" => API_ORIGIN + ) + end + + private + + def request(klass, path, body:, headers:) + refresh_session_if_needed unless path == "/api/v1/auth/web/session" + + uri = URI.join(API_ORIGIN, path) + request = klass.new(uri) + request["User-Agent"] = USER_AGENT + request["Accept"] = "application/json" + browser_headers.each { |key, value| request[key] = value } + request["Cookie"] = cookie_header if @cookies.any? + headers.each { |key, value| request[key] = value } + if body + request["Content-Type"] = "application/json" + request.body = JSON.generate(body) + end + + response = Net::HTTP.start( + uri.host, + uri.port, + use_ssl: uri.scheme == "https", + open_timeout: timeout_seconds, + read_timeout: timeout_seconds + ) do |http| + http.request(request) + end + update_cookies(response) + response + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise Provider::TradeRepublicClient::Timeout, "Trade Republic network request timed out: #{e.class}" + rescue SocketError, Errno::ECONNRESET => e + raise Provider::TradeRepublicClient::TransientProviderError, "Trade Republic network request failed: #{e.class}" + end + + def refresh_session_if_needed + return if Time.current < @session_expires_at + + response = request(Net::HTTP::Get, "/api/v1/auth/web/session", body: nil, headers: {}) + response.value + @session_expires_at = 290.seconds.from_now + rescue Net::HTTPClientException + # Authentication endpoints may be called before a session exists. + @session_expires_at = 290.seconds.from_now + rescue Net::HTTPFatalError, Net::HTTPRetriableError => e + raise Provider::TradeRepublicClient::TransientProviderError, + "Trade Republic session refresh failed: #{e.class}" + end + + def update_cookies(response) + Array(response.get_fields("set-cookie")).each do |header| + pair = header.split(";", 2).first + name, value = pair.split("=", 2) + @cookies[name] = value if name.present? && value.present? + end + end + + def browser_headers + { + "Origin" => API_ORIGIN, + "Referer" => "#{API_ORIGIN}/", + "Accept-Language" => "en-US,en;q=0.9", + "Sec-Fetch-Dest" => "empty", + "Sec-Fetch-Mode" => "cors", + "Sec-Fetch-Site" => "same-site" + }.tap do |headers| + waf_token = ENV["TRADE_REPUBLIC_WAF_TOKEN"].presence + headers["X-aws-waf-token"] = waf_token if waf_token + end + end + + def decode_cookies(blob) + return {} if blob.blank? + + parsed = JSON.parse(blob) + return parse_netscape_cookies(blob) unless parsed.is_a?(Hash) + + parsed.stringify_keys + rescue JSON::ParserError + parse_netscape_cookies(blob) + end + + def parse_netscape_cookies(blob) + blob.each_line.with_object({}) do |line, cookies| + next if line.start_with?("#") || line.strip.blank? + + fields = line.strip.split("\t") + cookies[fields[-2]] = fields[-1] if fields.length >= 7 + end + end + + def device_info + @device_info ||= Base64.strict_encode64(JSON.generate( + stableDeviceId: stable_device_id, + browser: "Chrome", + browserVersion: USER_AGENT[/Chrome\/([\d.]+)/, 1].to_s, + device: "Desktop", + deviceType: "desktop", + os: Etc.uname[:sysname].to_s, + osVersion: Etc.uname[:release].to_s, + timezone: Time.zone.tzinfo.name, + timezoneOffset: -Time.zone.utc_offset / 60, + screen: "1920x1080x24", + preferredLanguages: [ "en" ], + numberOfCores: Etc.nprocessors + )) + end + + def stable_device_id + configured = ENV["TRADE_REPUBLIC_DEVICE_ID"].presence + return Digest::SHA512.hexdigest(configured) if configured + + Digest::SHA512.hexdigest([ Socket.gethostname, RUBY_PLATFORM, Etc.uname[:machine], Etc.uname[:sysname] ].join("|")) + end + + def timeout_seconds + ENV.fetch("TRADE_REPUBLIC_HTTP_TIMEOUT_SECONDS", 30).to_i + end +end diff --git a/app/models/provider/trade_republic_websocket.rb b/app/models/provider/trade_republic_websocket.rb new file mode 100644 index 000000000..c4268807e --- /dev/null +++ b/app/models/provider/trade_republic_websocket.rb @@ -0,0 +1,108 @@ +require "json" +require "net/http" +require "openssl" +require "socket" +require "timeout" +require "websocket/driver" + +class Provider::TradeRepublicWebsocket + HOST = "api.traderepublic.com" + URL = "wss://#{HOST}" + + def initialize(headers:, timeout: 30) + @headers = headers + @timeout = timeout + @messages = Queue.new + @socket = nil + @driver = nil + end + + def connect + tcp = Socket.tcp(HOST, 443, connect_timeout: @timeout) + context = OpenSSL::SSL::SSLContext.new + context.set_params + @socket = OpenSSL::SSL::SSLSocket.new(tcp, context) + @socket.hostname = HOST + @socket.sync_close = true + Timeout.timeout(@timeout, Timeout::Error) { @socket.connect } + @socket.post_connection_check(HOST) + + adapter = SocketAdapter.new(@socket) + @driver = WebSocket::Driver.client(adapter) + @headers.each { |key, value| @driver.set_header(key, value) } + @driver.on(:message) { |event| @messages << event.data } + @driver.on(:error) { |event| raise Provider::TradeRepublicClient::ProviderUnavailable, event.message } + @driver.start + read_until { @driver.state == :open } + self + rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e + close + raise Provider::TradeRepublicClient::TransientProviderError, "Trade Republic WebSocket connection failed: #{e.class}" + rescue Timeout::Error + close + raise Provider::TradeRepublicClient::Timeout, "Trade Republic WebSocket connection timed out" + end + + def send_text(payload) + @driver.text(payload) + end + + def receive + return @messages.pop(true) unless @messages.empty? + + loop do + read_once + return @messages.pop(true) unless @messages.empty? + end + rescue ThreadError + retry + rescue Timeout::Error + raise Provider::TradeRepublicClient::Timeout, "Trade Republic WebSocket timed out" + rescue EOFError, IOError => e + raise Provider::TradeRepublicClient::TransientProviderError, "Trade Republic WebSocket closed unexpectedly: #{e.class}" + end + + def close + @driver&.close if @driver&.state == :open + @socket&.close + rescue IOError + nil + ensure + @driver = nil + @socket = nil + end + + private + + def read_until + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout + until yield + raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + + read_once + end + rescue Timeout::Error + close + raise Provider::TradeRepublicClient::Timeout, "Trade Republic WebSocket timed out" + end + + def read_once + ready = IO.select([ @socket ], nil, nil, @timeout) + raise Timeout::Error unless ready + + @driver.parse(@socket.readpartial(64 * 1024)) + end + + class SocketAdapter + attr_reader :url + + def initialize(socket) + @socket = socket + @url = URL + end + + def write(data) + @socket.write(data) + end + end +end diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index 8586eadf0..a90946b86 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -20,6 +20,7 @@ class ProviderConnectionStatus { key: "sophtron", type: "SophtronItem", association: :sophtron_items, accounts: :sophtron_accounts }, { key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts }, { key: "trading212", type: "Trading212Item", association: :trading212_items, accounts: :trading212_accounts }, + { key: "trade_republic", type: "TradeRepublicItem", association: :trade_republic_items, accounts: :trade_republic_accounts }, { key: "questrade", type: "QuestradeItem", association: :questrade_items, accounts: :questrade_accounts }, { key: "redbark", type: "RedbarkItem", association: :redbark_items, accounts: :redbark_accounts }, { key: "wise", type: "WiseItem", association: :wise_items, accounts: :wise_accounts } diff --git a/app/models/trade_republic_account.rb b/app/models/trade_republic_account.rb new file mode 100644 index 000000000..299e44d32 --- /dev/null +++ b/app/models/trade_republic_account.rb @@ -0,0 +1,58 @@ +class TradeRepublicAccount < ApplicationRecord + include CurrencyNormalizable, Encryptable + include TradeRepublicAccount::DataHelpers + + if encryption_ready? + encrypts :raw_positions_payload + encrypts :raw_timeline_payload + end + + belongs_to :trade_republic_item + + # The provider model can be loaded while Rails is booting before the + # development schema cache has refreshed after a migration. Declaring the + # type explicitly keeps the enum valid in that reload window as well. + attribute :kind, :string, default: "portfolio" + enum :kind, { portfolio: "portfolio", cash: "cash" }, default: :portfolio + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :currency, presence: true + validates :trade_republic_account_id, uniqueness: { scope: :trade_republic_item_id, allow_nil: true } + + def current_account + account || linked_account + end + + def ensure_account_provider!(account = nil) + if account_provider.present? + account_provider.update!(account: account) if account && account_provider.account_id != account.id + return account_provider + end + + acct = account || current_account + return nil unless acct + + provider = AccountProvider + .find_or_initialize_by(provider_type: "TradeRepublicAccount", provider_id: id) + .tap do |record| + record.account = acct + record.save! + end + + reload_account_provider + provider + rescue => e + DebugLogEntry.capture( + category: "sync", + level: "warn", + message: "TradeRepublicAccount##{id}: failed to ensure AccountProvider link: #{e.class} - #{e.message}", + source: "trade_republic", + family: trade_republic_item.family, + provider_key: "trade_republic" + ) + nil + end +end diff --git a/app/models/trade_republic_account/activities_processor.rb b/app/models/trade_republic_account/activities_processor.rb new file mode 100644 index 000000000..a79afcb08 --- /dev/null +++ b/app/models/trade_republic_account/activities_processor.rb @@ -0,0 +1,282 @@ +class TradeRepublicAccount::ActivitiesProcessor + include TradeRepublicAccount::DataHelpers + + # Timeline event categories that carry trade payloads once their detail has + # been resolved by the Trade Republic client boundary. + CATEGORY_ORDER_EXECUTION = "orderExecution" + + def initialize(trade_republic_account) + @trade_republic_account = trade_republic_account + end + + def process + return { trades: 0, transactions: 0 } unless account.present? + + trade_count = 0 + transaction_count = 0 + split_accounts = linked_cash_account_present? + + Array(@trade_republic_account.raw_timeline_payload).each do |event| + next unless event.is_a?(Hash) + + next if split_accounts && @trade_republic_account.portfolio? && event.with_indifferent_access[:category].to_s != CATEGORY_ORDER_EXECUTION + next if @trade_republic_account.cash? && event.with_indifferent_access[:category].to_s == CATEGORY_ORDER_EXECUTION + + case process_event(event.with_indifferent_access) + when :trade then trade_count += 1 + when :transaction then transaction_count += 1 + end + end + + reconcile_split_portfolio_transactions! + + { trades: trade_count, transactions: transaction_count } + end + + private + + def i18n_scope + "trade_republic_items.activities.labels" + end + + def t(key, **options) + I18n.t(key, scope: i18n_scope, **options) + end + + def account + @trade_republic_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def currency + @trade_republic_account.currency + end + + # Events arrive bridge-normalized: + # { id:, timestamp:, category:, title:, subtitle:, + # detail: { isin, name, quantity (signed), amount (magnitude), + # currency, fees, taxes } } + # detail is present only for events the bridge could normalize; unknown or + # ambiguous events are skipped with a debug-log entry, never guessed. + def process_event(event) + external_id = "trade_republic_event_#{event[:id]}" + return nil if event[:id].blank? + + date = parse_date(event[:timestamp]) + return nil unless date + + detail = event[:detail] || {} + + case event_category(event) + when CATEGORY_ORDER_EXECUTION + import_order_execution(event, detail, external_id, date) ? :trade : nil + when CATEGORY_DEPOSIT + import_cash_movement(event, detail, external_id, date, label: cash_label(event, default: t("contribution")), sign: -1) ? :transaction : nil + when CATEGORY_WITHDRAWAL + import_cash_movement(event, detail, external_id, date, label: cash_label(event, default: t("withdrawal")), sign: 1) ? :transaction : nil + when CATEGORY_INTEREST + import_cash_movement(event, detail, external_id, date, label: t("interest"), sign: -1) ? :transaction : nil + when CATEGORY_DIVIDEND + import_cash_movement(event, detail, external_id, date, label: t("dividend"), sign: -1) ? :transaction : nil + else + record_unknown_event(event) + nil + end + rescue => e + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "TradeRepublicAccount::ActivitiesProcessor - Failed to process event #{event[:id]}: #{e.message}", + source: "trade_republic", + family: @trade_republic_account.trade_republic_item.family, + provider_key: "trade_republic", + metadata: { event_id: event[:id], category: event[:category] } + ) + nil + end + + def import_order_execution(event, detail, external_id, date) + isin = detail[:isin].to_s + quantity = parse_decimal(detail[:quantity]) + + return false if isin.blank? || quantity.nil? || quantity.zero? + + security = resolve_security(isin, detail[:name] || event[:title]) + return false unless security + + is_buy = quantity.positive? + signed_quantity = quantity # Bridge reports sells as negative quantities already + + # Amount falls back to |quantity| × price only when the provider omits + # the exact cash amount. Fees and taxes stay embedded in the provider + # amount rather than being inferred separately. + price = parse_decimal(detail[:price]) + price = nil if price&.zero? + amount = parse_decimal(detail[:amount]) + amount = quantity.abs * price.abs if (!amount || amount.zero?) && price + return false unless amount && !amount.zero? + + signed_amount = is_buy ? -amount.abs : amount.abs + price ||= amount.abs / signed_quantity.abs + + entry = import_adapter.import_trade( + external_id: external_id, + security: security, + quantity: signed_quantity, + price: price, + amount: signed_amount, + currency: detail[:currency].presence || currency, + date: date, + name: build_trade_name(security.ticker, signed_quantity), + source: "trade_republic", + activity_label: is_buy ? t("buy") : t("sell") + ) + + trade_metadata = { + trade_republic: { + event_id: event[:id], + event_type: event[:eventType], + isin: isin, + fees: detail[:fees], + taxes: detail[:taxes], + provider_name: detail[:name] + }.compact + } + + if entry&.entryable.is_a?(Trade) && trade_metadata[:trade_republic].present? + existing = entry.entryable.extra || {} + merged = existing.deep_merge(trade_metadata.deep_stringify_keys) + entry.entryable.update!(extra: merged) if merged != existing + end + + true + end + + def import_cash_movement(event, detail, external_id, date, label:, sign:) + amount = parse_decimal(detail[:amount]) + return false unless amount && !amount.zero? + + import_adapter.import_transaction( + external_id: external_id, + # The normalized category is the source of truth for direction. TR + # payloads use different signs across timeline topics, so forwarding + # `detail[:signed_amount]` would turn deposits into withdrawals (and + # vice versa) depending on which topic produced the event. + amount: sign * amount.abs, + currency: detail[:currency].presence || currency, + date: date, + name: event[:title].presence || label, + notes: event[:subtitle].presence, + source: "trade_republic", + category_id: category_for(event, label)&.id, + kind: transfer_event?(event) ? "funds_movement" : nil, + investment_activity_label: label, + extra: { + trade_republic: { + event_id: detail[:event_id] || external_id, + category: detail[:category], + event_type: event[:eventType], + title: event[:title], + subtitle: event[:subtitle], + provider_detail: detail.except(:amount, :signed_amount, :currency) + }.compact + } + ) + + true + end + + def category_for(event, label) + nil + end + + def transfer_event?(event) + TRANSFER_EVENT_TYPES.include?(event[:eventType].to_s) + end + + # Older stored snapshots may still contain CARD_CASH_BACK as + # PAYMENT_RECEIVED. Trade Republic uses that event type for some card + # purchases, where the signed provider amount is negative. Normalize this + # legacy shape before applying the standard cash direction rules. + def event_category(event) + signed_amount = parse_decimal(event.dig(:detail, :signed_amount) || event.dig(:detail, :amount)) + return CATEGORY_WITHDRAWAL if event[:eventType].to_s == "CARD_CASH_BACK" && signed_amount&.negative? + + event[:category].to_s + end + + def cash_label(event, default:) + case event[:eventType].to_s + when "CARD_TRANSACTION", "card_successful_transaction" + t("card_payment") + when "CARD_ATM_WITHDRAWAL" + t("cash_withdrawal") + when "CARD_ORDER_FEE" + t("card_fee") + when "CARD_CASH_BACK" + t("card_payment") + when "card_refund", "CARD_REFUND" + t("card_refund") + when "TAX_REFUND", "SSP_TAX_CORRECTION", "ssp_tax_correction_invoice" + t("tax_refund") + else + default + end + end + + def reconcile_split_portfolio_transactions! + return unless @trade_republic_account.portfolio? && linked_cash_account_present? + cash_account = @trade_republic_account.trade_republic_item.trade_republic_accounts.find_by(kind: "cash") + return unless cash_account + + cash_event_ids = Array(cash_account.raw_timeline_payload).filter_map do |event| + event["id"].presence if event.is_a?(Hash) + end + return if cash_event_ids.empty? + + stale_entries = account.entries + .where(source: "trade_republic", entryable_type: "Transaction") + .where(external_id: cash_event_ids.map { |event_id| "trade_republic_event_#{event_id}" }) + removed_count = stale_entries.count + stale_entries.destroy_all if removed_count.positive? + return unless removed_count.positive? + + DebugLogEntry.capture( + category: "sync", + level: "info", + message: "Removed #{removed_count} legacy cash transaction(s) from split Trade Republic portfolio", + source: "trade_republic", + family: @trade_republic_account.trade_republic_item.family, + provider_key: "trade_republic", + account: account, + metadata: { trade_republic_account_id: @trade_republic_account.id, removed_count: removed_count } + ) + end + + def linked_cash_account_present? + @trade_republic_account.trade_republic_item.trade_republic_accounts + .where(kind: "cash") + .joins(:account_provider) + .exists? + end + + def record_unknown_event(event) + DebugLogEntry.capture( + category: "sync", + level: "info", + message: "TradeRepublicAccount::ActivitiesProcessor - Skipping unsupported timeline event (no guessed mapping)", + source: "trade_republic", + family: @trade_republic_account.trade_republic_item.family, + provider_key: "trade_republic", + metadata: { event_id: event[:id], category: event[:category] } + ) + end + + def build_trade_name(ticker, signed_quantity) + action = signed_quantity.negative? ? t("sell") : t("buy") + "#{action} #{signed_quantity.abs} shares of #{ticker}" + end +end diff --git a/app/models/trade_republic_account/data_helpers.rb b/app/models/trade_republic_account/data_helpers.rb new file mode 100644 index 000000000..bf427c810 --- /dev/null +++ b/app/models/trade_republic_account/data_helpers.rb @@ -0,0 +1,57 @@ +module TradeRepublicAccount::DataHelpers + extend ActiveSupport::Concern + + # Timeline event categories Trade Republic emits. Only explicitly mapped + # categories are imported; anything unknown is skipped and recorded rather + # than guessed into a transaction. + CATEGORY_DEPOSIT = "PAYMENT_RECEIVED" + CATEGORY_WITHDRAWAL = "POC_CREATED" + CATEGORY_INTEREST = "INTEREST_PAYOUT_CREATED" + CATEGORY_DIVIDEND = "DIVIDEND" + KNOWN_ACTIVITY_CATEGORIES = [ CATEGORY_DEPOSIT, CATEGORY_WITHDRAWAL, CATEGORY_INTEREST, CATEGORY_DIVIDEND, "orderExecution" ].freeze + + TRANSFER_EVENT_TYPES = %w[ + PAYMENT_INBOUND PAYMENT_OUTBOUND INCOMING_TRANSFER OUTGOING_TRANSFER + INCOMING_TRANSFER_DELEGATION OUTGOING_TRANSFER_DELEGATION + ].freeze + + private + + def parse_decimal(value) + return nil if value.nil? + + normalized = value.is_a?(String) ? value.strip : value.to_s + return nil if normalized.blank? + + BigDecimal(normalized) + rescue ArgumentError + nil + end + + def parse_date(value) + return nil if value.blank? + + case value + when DateTime, Time, ActiveSupport::TimeWithZone + value.to_date + when Date + value + else + Time.zone.parse(value.to_s)&.to_date || Date.parse(value.to_s) + end + rescue ArgumentError, TypeError + nil + end + + # Resolve (or create) a Security from a Trade Republic position. The ISIN + # is the stable provider identifier; ticker matching falls back to it + # because the securities table has no ISIN column. + def resolve_security(isin, name) + return nil if isin.blank? + + Security.find_by(ticker: isin) || + Security.create!(ticker: isin, name: name.presence || isin) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique + Security.find_by(ticker: isin) + end +end diff --git a/app/models/trade_republic_account/holdings_processor.rb b/app/models/trade_republic_account/holdings_processor.rb new file mode 100644 index 000000000..055d9b9fa --- /dev/null +++ b/app/models/trade_republic_account/holdings_processor.rb @@ -0,0 +1,95 @@ +class TradeRepublicAccount::HoldingsProcessor + include TradeRepublicAccount::DataHelpers + + def initialize(trade_republic_account) + @trade_republic_account = trade_republic_account + end + + def process + return unless account.present? + + positions = Array(@trade_republic_account.raw_positions_payload) + processed_count = positions.count do |position| + process_position(position.with_indifferent_access) + end + + # A validated, complete snapshot is authoritative. Reconcile only after + # every position was imported successfully; partial provider data must + # preserve existing holdings. + if @trade_republic_account.holdings_snapshot_complete? && processed_count == positions.size + reconcile_stale_holdings!(positions) + end + end + + private + + def account + @trade_republic_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def currency + @trade_republic_account.currency + end + + def process_position(position) + isin = position[:isin].to_s + return if isin.blank? + + security = resolve_security(isin, position[:name]) + return unless security + + quantity = parse_decimal(position[:quantity]) + price = parse_decimal(position[:price]) + return unless quantity && price && quantity.positive? + + amount = quantity * price + date = Date.current + + external_id = "trade_republic_position_#{@trade_republic_account.trade_republic_account_id}_#{isin}_#{date}" + + import_adapter.import_holding( + security: security, + quantity: quantity, + amount: amount, + currency: currency, + date: date, + price: price, + cost_basis: parse_decimal(position[:average_cost]), + external_id: external_id, + source: "trade_republic", + account_provider_id: @trade_republic_account.account_provider&.id, + delete_future_holdings: false + ) + true + rescue => e + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "TradeRepublicAccount::HoldingsProcessor - Failed to process position #{isin}: #{e.message}", + source: "trade_republic", + family: @trade_republic_account.trade_republic_item.family, + provider_key: "trade_republic", + metadata: { isin: isin, trade_republic_account_id: @trade_republic_account.id } + ) + false + end + + def reconcile_stale_holdings!(positions) + provider_id = @trade_republic_account.account_provider&.id + return if provider_id.blank? + + prefix = "trade_republic_position_#{@trade_republic_account.trade_republic_account_id}_" + current_ids = positions.filter_map do |position| + isin = position.with_indifferent_access[:isin].to_s + isin.present? ? "#{prefix}#{isin}_#{Date.current}" : nil + end + holdings = account.holdings.where(account_provider_id: provider_id) + .where("external_id LIKE ?", "#{prefix}%#{Date.current}") + holdings = holdings.where.not(external_id: current_ids) if current_ids.any? + holdings.destroy_all + end +end diff --git a/app/models/trade_republic_account/processor.rb b/app/models/trade_republic_account/processor.rb new file mode 100644 index 000000000..621b806bc --- /dev/null +++ b/app/models/trade_republic_account/processor.rb @@ -0,0 +1,38 @@ +class TradeRepublicAccount::Processor + attr_reader :trade_republic_account + + def initialize(trade_republic_account) + @trade_republic_account = trade_republic_account + end + + def process + return unless account.present? + + ActiveRecord::Base.transaction do + update_account_balance! + TradeRepublicAccount::HoldingsProcessor.new(trade_republic_account).process + TradeRepublicAccount::ActivitiesProcessor.new(trade_republic_account).process + end + + account.broadcast_sync_complete + end + + private + + def account + @account ||= trade_republic_account.current_account + end + + def update_account_balance! + total_balance = trade_republic_account.current_balance || 0 + cash_balance = trade_republic_account.cash_balance || 0 + + account.assign_attributes( + balance: total_balance, + cash_balance: trade_republic_account.cash? ? cash_balance : 0, + currency: trade_republic_account.currency + ) + account.save! + account.set_current_balance(total_balance) + end +end diff --git a/app/models/trade_republic_client_categories.rb b/app/models/trade_republic_client_categories.rb new file mode 100644 index 000000000..9ff3368c3 --- /dev/null +++ b/app/models/trade_republic_client_categories.rb @@ -0,0 +1,3 @@ +module TradeRepublicClientCategories + ALL = %w[brokerage private_markets interest_products crypto_wallet].freeze +end diff --git a/app/models/trade_republic_item.rb b/app/models/trade_republic_item.rb new file mode 100644 index 000000000..1b3f0f9e8 --- /dev/null +++ b/app/models/trade_republic_item.rb @@ -0,0 +1,214 @@ +class TradeRepublicItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + # The PIN is accepted only for the authentication request. It is never + # persisted; session restoration uses the encrypted session blob instead. + attr_accessor :pin + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + if encryption_ready? + encrypts :phone_number, deterministic: true + encrypts :session_blob + encrypts :pending_login_state + end + + belongs_to :family + has_many :trade_republic_accounts, dependent: :destroy + + # QR login does not require the phone number or PIN. The account is created + # in requires_update state first and receives its authenticated session after + # the QR challenge is approved in the Trade Republic app. + validates :phone_number, presence: true, unless: -> { requires_update? || session_configured? } + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active.where(status: :good, pending_login_state: nil).where.not(session_blob: [ nil, "" ]) } + 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 + + # Reloading represents a new persisted state; never carry an authentication + # PIN across that boundary in the in-memory model instance. + def reload(*) + super.tap { @pin = nil } + end + + def credentials_configured? + phone_number.present? || session_configured? + end + + def session_configured? + session_blob.present? + end + + def ready_for_sync? + good? && pending_login_state.blank? && session_configured? + end + + def import_latest_data + provider = trade_republic_provider + raise Provider::TradeRepublicClient::ConfigurationError, "Trade Republic connection is not configured" unless provider + + TradeRepublicItem::Importer.new(self, provider: provider).import + rescue => e + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "TradeRepublicItem #{id} - Failed to import data: #{e.message}", + source: "trade_republic", + family: family, + provider_key: "trade_republic" + ) + raise + end + + def process_accounts + return [] if trade_republic_accounts.empty? + + linked_trade_republic_accounts.includes(account_provider: :account).each_with_object([]) do |tr_account, results| + account = tr_account.current_account + next unless account + next if account.pending_deletion? || account.disabled? + + begin + result = TradeRepublicAccount::Processor.new(tr_account).process + results << { trade_republic_account_id: tr_account.id, success: true, result: result } + rescue => e + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "TradeRepublicItem #{id} - Failed to process account #{tr_account.id}: #{e.message}", + source: "trade_republic", + family: family, + provider_key: "trade_republic", + account_provider_id: tr_account.account_provider&.id + ) + results << { trade_republic_account_id: tr_account.id, success: false, error: e.message } + end + end + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + accounts.reject { |account| account.pending_deletion? || account.disabled? }.each_with_object([]) do |account, results| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "TradeRepublicItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}", + source: "trade_republic", + family: family, + provider_key: "trade_republic", + account_id: account.id + ) + results << { account_id: account.id, success: false, error: e.message } + end + end + end + + def accounts + trade_republic_accounts.includes(account_provider: :account).filter_map(&:current_account).uniq + end + + def linked_trade_republic_accounts + trade_republic_accounts.joins(:account_provider) + end + + def linked_accounts_count + trade_republic_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + trade_republic_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + trade_republic_accounts.count + end + + def has_completed_initial_setup? + accounts.any? + end + + def sync_status_summary + total = total_accounts_count + linked = linked_accounts_count + unlinked = unlinked_accounts_count + + if total.zero? + I18n.t("trade_republic_items.sync_status.no_accounts") + elsif unlinked.zero? + I18n.t("trade_republic_items.sync_status.all_linked", count: linked) + else + I18n.t("trade_republic_items.sync_status.partial", linked: linked, unlinked: unlinked) + end + end + + def institution_display_name + I18n.t("trade_republic_items.defaults.name") + end + + def sync_history(limit: 5) + syncs.ordered.limit(limit) + end + + def data_quality_summary + positions = trade_republic_accounts.where(kind: "portfolio").flat_map { |account| Array(account.raw_positions_payload) } + events = trade_republic_accounts.flat_map { |account| Array(account.raw_timeline_payload) } + unknown_events = events.count do |event| + !event.is_a?(Hash) || !TradeRepublicAccount::DataHelpers::KNOWN_ACTIVITY_CATEGORIES.include?(event["category"]) + end + + { + positions: positions.size, + unpriced_positions: positions.count { |position| position["price"].blank? }, + events: events.size, + unknown_events: unknown_events, + linked_accounts: linked_accounts_count, + unlinked_accounts: unlinked_accounts_count + } + end + + def reconciliation_summary + trade_republic_accounts.filter_map do |provider_account| + account = provider_account.current_account + next if account.blank? + + expected = provider_account.current_balance.to_d + actual = account.balance.to_d + difference = (expected - actual).abs + { + kind: provider_account.kind, + account_id: account.id, + expected: expected, + actual: actual, + difference: difference, + reconciled: difference < BigDecimal("0.01") + } + end + end + + def expense_summary(days: 30) + entries = accounts.flat_map do |account| + account.entries.where(source: "trade_republic", entryable_type: "Transaction") + .where(date: days.days.ago.to_date..Date.current) + end + expenses = entries.select { |entry| entry.amount.to_d.positive? } + + { + count: expenses.size, + total: expenses.sum { |entry| entry.amount.to_d }, + days: days + } + end +end diff --git a/app/models/trade_republic_item/importer.rb b/app/models/trade_republic_item/importer.rb new file mode 100644 index 000000000..a24429d7e --- /dev/null +++ b/app/models/trade_republic_item/importer.rb @@ -0,0 +1,232 @@ +class TradeRepublicItem::Importer + MAX_TIMELINE_EVENTS = 5_000 + # Fetches the latest Trade Republic state through the provider client and + # stores normalized raw payloads on the item's accounts. + # + # Failure semantics: any provider error propagates without touching stored + # payloads, so a failed sync can never erase known financial state. The only + # destructive reconciliation (stale holdings cleanup) happens later in + # TradeRepublicAccount::Processor after a fully validated response. + + attr_reader :trade_republic_item, :provider + + def initialize(trade_republic_item, provider:) + @trade_republic_item = trade_republic_item + @provider = provider + end + + def import + result = provider.sync( + session_txt: trade_republic_item.session_blob, + known_newest_event_id: known_newest_event_id + ) + + data = result.data + domain_statuses = normalized_domain_statuses(data) + + if data["status"] == "session_expired" + trade_republic_item.update!(status: :requires_update) + raise Provider::TradeRepublicClient::AuthenticationRequired, + "Trade Republic session expired. Re-authentication required." + end + + ActiveRecord::Base.transaction do + upsert_account(data, domain_statuses: domain_statuses) + trade_republic_item.update!( + status: :good, + newest_event_id: domain_statuses["timeline"] == "success" && data["newest_event_id"].present? ? data["newest_event_id"] : trade_republic_item.newest_event_id, + session_blob: data["session_txt"].presence || trade_republic_item.session_blob + ) + end + + record_provider_warnings(data["warnings"]) + + { success: true } + end + + private + + def upsert_account(data, domain_statuses:) + account_info = data["account"] || {} + unless domain_statuses["account_metadata"] == "success" + raise Provider::TradeRepublicClient::MalformedResponse, + "Trade Republic account metadata was not fetched successfully" + end + + account_id = account_info["brokerage_account_id"].presence + if account_id.blank? + raise Provider::TradeRepublicClient::MalformedResponse, + "Trade Republic response did not contain a brokerage account ID" + end + currency = account_info["currency"].presence || + trade_republic_item.currency.presence || + trade_republic_item.family.currency + portfolio_account = trade_republic_item.trade_republic_accounts.find_by(kind: "portfolio") + + upsert_kind( + kind: "portfolio", + external_id: account_id, + name: build_account_name(account_id, kind: "portfolio"), + currency: currency, + current_balance: portfolio_balance(data, fallback: portfolio_account&.current_balance), + cash_balance: 0, + positions: Array(data["positions"]), + events: data["events"], + warnings: position_warnings(data), + domain_statuses: domain_statuses + ) + upsert_kind( + kind: "cash", + external_id: "cash:#{account_id}", + name: build_account_name(account_id, kind: "cash"), + currency: currency, + current_balance: cash_balance(data), + cash_balance: cash_balance(data), + positions: [], + events: Array(data["events"]).reject { |event| event["category"] == "orderExecution" }, + warnings: [], + domain_statuses: domain_statuses + ) + end + + def upsert_kind(kind:, external_id:, name:, currency:, current_balance:, cash_balance:, positions:, events:, warnings:, domain_statuses:) + tr_account = trade_republic_item.trade_republic_accounts.find_by(trade_republic_account_id: external_id) || + trade_republic_item.trade_republic_accounts.find_or_initialize_by(kind: kind) + portfolio_status = domain_statuses["portfolio"] + cash_status = domain_statuses["cash"] + timeline_status = domain_statuses["timeline"] + domain_status = kind == "portfolio" ? portfolio_status : cash_status + attrs = { + trade_republic_account_id: external_id, + name: name, + currency: currency + } + + if domain_status != "failed" + if kind == "portfolio" + attrs[:current_balance] = portfolio_status == "success" ? current_balance : tr_account.current_balance + attrs[:cash_balance] = cash_balance + attrs[:raw_positions_payload] = merge_position_prices(tr_account.raw_positions_payload, positions) + attrs[:holdings_snapshot_complete] = portfolio_status == "success" && Array(warnings).empty? + attrs[:last_positions_sync] = Time.current + else + attrs[:current_balance] = current_balance + attrs[:cash_balance] = cash_balance + end + end + + if timeline_status != "failed" + attrs[:raw_timeline_payload] = merge_timeline_events(tr_account.raw_timeline_payload, events) + end + + tr_account.assign_attributes(attrs) + tr_account.save! + end + + def normalized_domain_statuses(data) + explicit = data["domain_statuses"] + return explicit.stringify_keys if explicit.is_a?(Hash) + + { + "account_metadata" => data["account"].present? ? "success" : "failed", + "cash" => data.key?("cash") && data["cash"].present? ? "success" : "failed", + "portfolio" => data.key?("positions") ? "success" : "failed", + "timeline" => data.key?("events") ? "success" : "failed", + "instrument_metadata" => position_warnings(data).empty? ? "success" : "partial" + } + end + + def position_warnings(data) + return Array(data["position_warnings"]) if data.key?("position_warnings") + return [] if data.key?("domain_statuses") + + Array(data["warnings"]).grep(/price unavailable/i) + end + + def merge_position_prices(existing, incoming) + previous_prices = Array(existing).to_h do |position| + [ position["isin"], position["price"] ] + end + Array(incoming).map do |position| + position["price"].present? ? position : position.merge("price" => previous_prices[position["isin"]]) + end + end + + def known_newest_event_id + return if trade_republic_item.newest_event_id.blank? + + # A previous implementation could persist the newest cursor while + # dropping the actual event payload. Force one full timeline fetch in + # that state so historical data can be recovered instead of remaining + # permanently invisible. + portfolio_accounts = trade_republic_item.trade_republic_accounts.select(&:portfolio?) + return if portfolio_accounts.any? { |account| Array(account.raw_timeline_payload).blank? } + + trade_republic_item.newest_event_id + end + + def merge_timeline_events(existing, incoming) + events_by_id = {} + (Array(existing) + Array(incoming)).each do |event| + next unless event.is_a?(Hash) + + event = event.with_indifferent_access + key = event[:id].presence || event + events_by_id[key] = event + end + events_by_id.values.sort_by { |event| event[:timestamp].to_s }.last(MAX_TIMELINE_EVENTS) + end + + # Exact decimal math: cash + Σ(quantity × price). Positions lacking a + # validated price remain visible in the raw payload but contribute zero + # until Trade Republic provides a current quote. + def cash_balance(data) + parse_decimal(data.dig("cash", "available_amount")) || + parse_decimal(data.dig("cash", "amount")) || + parse_decimal(data.dig("cash", "value")) || BigDecimal("0") + end + + def portfolio_balance(data, fallback: nil) + positions = Array(data["positions"]) + return BigDecimal("0") if positions.empty? + + values = positions.map do |position| + quantity = parse_decimal(position["quantity"]) + price = parse_decimal(position["price"]) + next if quantity.nil? || price.nil? + + quantity * price + end + + return fallback if fallback.present? && values.any?(&:nil?) + + values.compact.sum(BigDecimal("0")) + end + + def build_account_name(account_id, kind:) + base = I18n.t("trade_republic_items.defaults.name") + suffix = kind == "cash" ? "Cash" : "Portfolio" + account_id.present? ? "#{base} #{suffix} (#{account_id})" : "#{base} #{suffix}" + end + + def record_provider_warnings(warnings) + Array(warnings).uniq.each do |warning| + DebugLogEntry.capture( + category: "sync", + level: "warn", + message: "Trade Republic sync warning: #{warning}", + source: "trade_republic", + family: trade_republic_item.family, + provider_key: "trade_republic", + metadata: { trade_republic_item_id: trade_republic_item.id } + ) + end + end + + def parse_decimal(value) + return nil if value.blank? + BigDecimal(value.to_s) + rescue ArgumentError + nil + end +end diff --git a/app/models/trade_republic_item/provided.rb b/app/models/trade_republic_item/provided.rb new file mode 100644 index 000000000..4e006504f --- /dev/null +++ b/app/models/trade_republic_item/provided.rb @@ -0,0 +1,32 @@ +module TradeRepublicItem::Provided + extend ActiveSupport::Concern + + def trade_republic_provider(pin: self.pin) + return nil unless credentials_configured? || pending_login_state.present? || session_configured? || requires_update? + + Provider::TradeRepublicClient.new( + phone_number: phone_number, + pin: pin + ) + end + + def login_method + return nil if pending_login_state.blank? + + return "qr" if trade_republic_provider&.qr_login?(pending_login_b64: pending_login_state) + + trade_republic_provider&.login_method(pending_login_b64: pending_login_state) + rescue Provider::TradeRepublicClient::Error + nil + end + + def login_stage + return nil if pending_login_state.blank? + + trade_republic_provider&.login_stage(pending_login_b64: pending_login_state) + rescue Provider::TradeRepublicClient::LoginExpired + "expired" + rescue Provider::TradeRepublicClient::Error + nil + end +end diff --git a/app/models/trade_republic_item/sync_complete_event.rb b/app/models/trade_republic_item/sync_complete_event.rb new file mode 100644 index 000000000..a332e6731 --- /dev/null +++ b/app/models/trade_republic_item/sync_complete_event.rb @@ -0,0 +1,22 @@ +class TradeRepublicItem::SyncCompleteEvent + attr_reader :trade_republic_item + + def initialize(trade_republic_item) + @trade_republic_item = trade_republic_item + end + + def broadcast + trade_republic_item.accounts.each do |account| + account.broadcast_sync_complete + end + + trade_republic_item.broadcast_replace_to( + trade_republic_item.family, + target: "trade_republic_item_#{trade_republic_item.id}", + partial: "trade_republic_items/trade_republic_item", + locals: { trade_republic_item: trade_republic_item } + ) + + trade_republic_item.family.broadcast_sync_complete + end +end diff --git a/app/models/trade_republic_item/syncer.rb b/app/models/trade_republic_item/syncer.rb new file mode 100644 index 000000000..edacd9ee4 --- /dev/null +++ b/app/models/trade_republic_item/syncer.rb @@ -0,0 +1,112 @@ +class TradeRepublicItem::Syncer + include SyncStats::Collector + + attr_reader :trade_republic_item + + def initialize(trade_republic_item) + @trade_republic_item = trade_republic_item + end + + def perform_sync(sync) + sync.update!(status_text: I18n.t("trade_republic_items.sync.status.checking_credentials")) if sync.respond_to?(:status_text) + unless trade_republic_item.credentials_configured? + trade_republic_item.update!(status: :requires_update) + raise Provider::TradeRepublicClient::ConfigurationError, + I18n.t("trade_republic_items.sync.errors.phone_number_missing") + end + unless trade_republic_item.ready_for_sync? + trade_republic_item.update!(status: :requires_update) + raise Provider::TradeRepublicClient::AuthenticationRequired, + I18n.t("trade_republic_items.sync.errors.reauthentication_required") + end + + sync.update!(status_text: I18n.t("trade_republic_items.sync.status.importing_account")) if sync.respond_to?(:status_text) + trade_republic_item.import_latest_data + collect_trade_republic_quality_stats(sync) + + sync.update!(status_text: I18n.t("trade_republic_items.sync.status.checking_configuration")) if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: trade_republic_item.trade_republic_accounts.to_a) + + unlinked_accounts = trade_republic_item.trade_republic_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + linked_accounts = trade_republic_item.trade_republic_accounts.joins(:account).merge(Account.visible) + + if unlinked_accounts.any? + trade_republic_item.update!(pending_account_setup: true) + sync.update!(status_text: I18n.t("trade_republic_items.sync.status.accounts_need_setup", count: unlinked_accounts.count)) if sync.respond_to?(:status_text) + else + trade_republic_item.update!(pending_account_setup: false) + end + + if linked_accounts.any? + sync.update!(status_text: I18n.t("trade_republic_items.sync.status.processing_activity")) if sync.respond_to?(:status_text) + process_results = trade_republic_item.process_accounts + raise_if_failed_results!(process_results, stage: "Trade Republic account processing") + + sync.update!(status_text: I18n.t("trade_republic_items.sync.status.calculating_balances")) if sync.respond_to?(:status_text) + schedule_results = trade_republic_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + raise_if_failed_results!(schedule_results, stage: "Trade Republic account sync scheduling") + + account_ids = linked_accounts.includes(:account).filter_map { |pa| pa.account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "trade_republic") if account_ids.any? + collect_trades_stats(sync, account_ids: account_ids, source: "trade_republic") if account_ids.any? + collect_holdings_stats(sync, holdings_count: count_holdings, label: "processed") + end + + collect_trade_republic_reconciliation_stats(sync) + + collect_health_stats(sync, errors: nil) + rescue Provider::TradeRepublicClient::AuthenticationRequired, + Provider::TradeRepublicClient::LoginExpired, + Provider::TradeRepublicClient::ConfigurationError => e + trade_republic_item.update!(status: :requires_update) + collect_health_stats(sync, errors: [ { message: e.message, category: "auth_error" } ]) + raise + rescue Provider::TradeRepublicClient::Error => e + collect_health_stats(sync, errors: [ { message: e.message, category: "provider_error" } ]) + raise + rescue => e + collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) + raise + end + + def perform_post_sync + end + + private + + def raise_if_failed_results!(results, stage:) + failed = Array(results).select { |result| result.is_a?(Hash) && result[:success] == false } + return if failed.empty? + + messages = failed.filter_map { |result| result[:error].presence } + raise Provider::TradeRepublicClient::ProviderUnavailable, + "#{stage} failed: #{messages.presence&.join(", ") || "unknown error"}" + end + + def count_holdings + trade_republic_item.trade_republic_accounts.sum { |acct| Array(acct.raw_positions_payload).size } + end + + def collect_trade_republic_quality_stats(sync) + stats = trade_republic_item.data_quality_summary + merge_sync_stats(sync, { + "tr_positions" => stats[:positions], + "tr_unpriced_positions" => stats[:unpriced_positions], + "tr_events" => stats[:events], + "tr_unknown_events" => stats[:unknown_events] + }) + end + + def collect_trade_republic_reconciliation_stats(sync) + checks = trade_republic_item.reconciliation_summary + merge_sync_stats(sync, { + "tr_reconciled_accounts" => checks.count { |check| check[:reconciled] }, + "tr_reconciliation_accounts" => checks.size, + "tr_reconciliation_difference" => checks.sum(BigDecimal("0")) { |check| check[:difference] }.to_s("F") + }) + end +end diff --git a/app/models/trade_republic_item/unlinking.rb b/app/models/trade_republic_item/unlinking.rb new file mode 100644 index 000000000..8ee8a86ff --- /dev/null +++ b/app/models/trade_republic_item/unlinking.rb @@ -0,0 +1,40 @@ +module TradeRepublicItem::Unlinking + extend ActiveSupport::Concern + + def unlink_all!(dry_run: false) + results = [] + + trade_republic_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "TradeRepublicAccount", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) if link_ids.any? + links.each(&:destroy!) + end + rescue => e + DebugLogEntry.capture( + category: "sync", + level: "error", + message: "TradeRepublicItem Unlinker: failed to fully unlink provider account ##{provider_account.id} " \ + "(links=#{link_ids.inspect}): #{e.class} - #{e.message}", + source: "trade_republic", + family: family, + provider_key: "trade_republic" + ) + result[:error] = e.message + end + end + + results + end +end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 6d13fae02..b282d8e5a 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -10,7 +10,7 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @redbark_items.empty? && @akahu_items.empty? && @up_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? && @kraken_items.empty? && @questrade_items.empty? && @wise_items.empty? && @onchain_wallet_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @redbark_items.empty? && @akahu_items.empty? && @up_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? && @kraken_items.empty? && @questrade_items.empty? && @wise_items.empty? && @onchain_wallet_items.empty? && @trade_republic_items.empty? %> <%= render "empty" %> <% else %>
<%= t("trade_republic_items.portfolio_categories.#{category}") %>
+<%= t(".positions", count: summary[:count]) %>
+<%= format_money Money.new(summary[:value].round(2), @account.currency) %>
+<%= t(".encryption_warning.title") %>
+<%= t(".encryption_warning.message") %>
+<%= t(".login_expired") %>
+ <%= render DS::Button.new( + text: t(".restart_login"), + variant: :outline, + size: :sm, + icon: "rotate-cw", + href: initiate_login_trade_republic_item_path(trade_republic_item), + method: :post, + form: { class: "ml-auto" } + ) %> + <% else %> + ++ <%= trade_republic_item.login_stage == "authenticator_code" ? t(".awaiting_authenticator_code") : t(".awaiting_confirmation") %> +
+ <% end %> + <% elsif trade_republic_item.persisted? && trade_republic_item.status == "good" && trade_republic_item.session_configured? %> + ++ <% if trade_republic_item.unlinked_accounts_count.positive? %> + <%= t(".accounts_discovered", count: trade_republic_item.unlinked_accounts_count) %> + <%= render DS::Link.new( + text: t(".setup_accounts"), + icon: "plus", + variant: "primary", + href: setup_accounts_trade_republic_item_path(trade_republic_item), + frame: :modal + ) %> + <% else %> + <%= t(".status_configured_prefix", summary: trade_republic_item.sync_status_summary) %> + <%= link_to t(".accounts_tab"), accounts_path, class: "link" %> + <%= t(".status_configured_suffix") %> + <% end %> +
+ <% elsif trade_republic_item.persisted? && trade_republic_item.status == "requires_update" %> + +<%= t(".requires_update") %>
+ <% else %> + +<%= t(".not_configured") %>
+ <% end %> ++ <%= t("settings.providers.trade_republic_panel.connection_success.message") %> +
++ <%= t("settings.providers.trade_republic_panel.connection_success.description") %> +
+<%= t(".deletion_in_progress") %>
+ <% end %> ++ <% if trade_republic_item.last_synced_at %> + <%= t(".synced", time: time_ago_in_words(trade_republic_item.last_synced_at), summary: trade_republic_item.sync_status_summary) %> + <% else %> + <%= t(".never_synced") %> + <% end %> +
+ <% end %> +<%= t(".data_quality.title") %>
+ <% if Current.user&.admin? %> + <%= button_to repair_trade_republic_item_path(trade_republic_item), method: :post, + class: "text-xs text-primary underline", data: { turbo: false } do %> + <%= t(".data_quality.repair") %> + <% end %> + <% end %> +<%= t(".data_quality.sync_history") %>
+ <% recent_syncs.each do |recent_sync| %> +<%= l(recent_sync.created_at, format: :short) %> · <%= t(".data_quality.sync_status.#{recent_sync.status}", default: recent_sync.status.to_s.humanize) %>
+ <% end %> +<%= t(".accounts_need_setup") %>
+<%= t(".accounts_need_setup_description") %>
+ <%= render DS::Link.new( + text: t(".setup_accounts"), + icon: "settings", + variant: "primary", + href: setup_accounts_trade_republic_item_path(trade_republic_item), + frame: :modal + ) %> +<%= t(".no_accounts_discovered") %>
+<%= t(".no_accounts_discovered_description") %>
+<%= t(".no_accounts_available") %>
+<%= t(".info_box.title") %>
+<%= t(".status.fetching_accounts") %>
+<%= t(".status.no_accounts_found_title") %>
+<%= t(".status.no_accounts_found_description") %>
+<%= t(".link_existing.description") %>
+<%= tr_account.name %>
+<%= t(".linked_accounts.linked_to_html", account: link_to(tr_account.current_account.name, account_path(tr_account.current_account), class: "link")) %>
+