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 %>
@@ -84,6 +84,10 @@ <%= render @wise_items.sort_by(&:created_at) %> <% end %> + <% if @trade_republic_items.any? %> + <%= render @trade_republic_items.sort_by(&:created_at) %> + <% end %> + <% if @snaptrade_items.any? %> <%= render @snaptrade_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/holdings/index.html.erb b/app/views/holdings/index.html.erb index 22eb0afa2..88b27b13f 100644 --- a/app/views/holdings/index.html.erb +++ b/app/views/holdings/index.html.erb @@ -13,6 +13,18 @@ <% end %>
+ <% if @trade_republic_categories.present? %> +
+ <% @trade_republic_categories.each do |category, summary| %> +
+

<%= t("trade_republic_items.portfolio_categories.#{category}") %>

+

<%= t(".positions", count: summary[:count]) %>

+

<%= format_money Money.new(summary[:value].round(2), @account.currency) %>

+
+ <% end %> +
+ <% end %> +
diff --git a/app/views/settings/providers/_trade_republic_panel.html.erb b/app/views/settings/providers/_trade_republic_panel.html.erb new file mode 100644 index 000000000..480f16156 --- /dev/null +++ b/app/views/settings/providers/_trade_republic_panel.html.erb @@ -0,0 +1,223 @@ +<% + trade_republic_item = local_assigns.fetch(:trade_republic_item) { Current.family.trade_republic_items.first_or_initialize } + is_new_record = trade_republic_item.new_record? + session_configured = trade_republic_item.persisted? && trade_republic_item.session_configured? + qr_login_pending = trade_republic_item.persisted? && trade_republic_item.login_stage == "qr_pending" + qr_login_active = local_assigns[:qr_login_auto_poll] || qr_login_pending +%> + +
+ data-controller="trade-republic-login" + data-trade-republic-login-url-value="<%= poll_login_trade_republic_item_path(trade_republic_item) %>" + <% end %>> + <% unless TradeRepublicItem.encryption_ready? %> +
+
+ <%= icon "shield-alert", size: "sm", class: "mt-0.5 shrink-0" %> +
+

<%= t(".encryption_warning.title") %>

+

<%= t(".encryption_warning.message") %>

+
+
+
+ <% end %> + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> + <%= render DS::Alert.new(message: error_msg, variant: :error) %> + <% end %> + + <%= render "settings/providers/setup_steps", + steps: [ + t(".steps.step_1"), + t(".steps.step_2"), + t(".steps.step_3") + ] %> + + <% if trade_republic_item.persisted? %> + <% if trade_republic_item.pending_login_state.blank? %> + <% if session_configured %> +
+ <%= render DS::Button.new( + text: t(".connect"), + variant: :outline, + size: :sm, + icon: "log-in", + href: initiate_login_trade_republic_item_path(trade_republic_item), + method: :post + ) %> + + <%= render DS::Button.new( + text: t(".sync"), + variant: :outline, + size: :sm, + icon: "refresh-cw", + href: sync_trade_republic_item_path(trade_republic_item), + method: :post, + disabled: trade_republic_item.syncing? + ) %> + + <%= render DS::Button.new( + variant: :outline_destructive, + size: :sm, + icon: "trash-2", + aria_label: t(".disconnect"), + title: t(".disconnect"), + href: trade_republic_item_path(trade_republic_item), + method: :delete, + confirm: t(".disconnect_confirm") + ) %> +
+ <% end %> + + <% end %> + + <% if trade_republic_item.pending_login_state.blank? || qr_login_pending %> +
" + data-trade-republic-qr-loading-text-value="<%= t(".qr_loading") %>" + data-trade-republic-qr-instruction-text-value="<%= t(".qr_instruction") %>" + data-trade-republic-qr-success-text-value="<%= t(".qr_success") %>" + data-trade-republic-qr-error-text-value="<%= t(".qr_error") %>" + data-trade-republic-qr-login-text-value="<%= t(".qr_login") %>" + data-trade-republic-qr-cancel-text-value="<%= t(".qr_cancel") %>"> + <%= render DS::Button.new( + text: qr_login_active ? t(".qr_cancel") : t(".qr_login"), + variant: :outline, + size: :sm, + icon: "qr-code", + type: "button", + data: { + action: "trade-republic-qr#toggle", + trade_republic_qr_target: "button" + } + ) %> + +
class="rounded-lg border border-secondary bg-container-inset p-4 space-y-3"> +

+
<%= local_assigns[:qr_code_svg] %>
+
+
+ <% end %> + + <% if trade_republic_item.login_stage == "authenticator_code" %> + <%= render DS::Alert.new( + message: t(".authenticator_code_notice"), + variant: :info + ) %> + + <%= styled_form_with url: complete_login_trade_republic_item_path(trade_republic_item), + method: :post, + scope: :trade_republic_login, + class: "space-y-3" do |form| %> + <%= form.text_field :code, + label: t(".authenticator_code_label"), + placeholder: t(".authenticator_code_placeholder"), + value: nil, + required: true %> +
+ <%= form.submit(t(".complete_login")) %> +
+ <% end %> + <% elsif trade_republic_item.login_stage == "waiting_for_approval" %> + <%= render DS::Alert.new(message: t(".push_login_notice"), variant: :info) %> +
+ <%= render DS::Button.new( + text: t(".check_login_status"), + variant: :outline, + size: :sm, + icon: "refresh-cw", + href: poll_login_trade_republic_item_path(trade_republic_item), + method: :post + ) %> +
+ <% end %> + <% end %> + + <% if trade_republic_item.new_record? || trade_republic_item.pending_login_state.blank? %> + <%= styled_form_with model: trade_republic_item, + url: is_new_record ? trade_republic_items_path : trade_republic_item_path(trade_republic_item), + scope: :trade_republic_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :phone_number, + label: t(".phone_number_label"), + placeholder: is_new_record ? t(".phone_number_placeholder_new") : t(".phone_number_placeholder_existing"), + type: :tel, + value: nil %> + + <%= form.text_field :pin, + label: t(".pin_label"), + placeholder: is_new_record ? t(".pin_placeholder_new") : t(".pin_placeholder_existing"), + type: :password, + value: nil %> + +
+ <%= form.submit(trade_republic_item.session_configured? ? t(".update_configuration") : t(".save_configuration")) %> + <% if is_new_record %> + <%= render DS::Button.new( + text: t(".qr_login"), + variant: :outline, + size: :sm, + icon: "qr-code", + type: "submit", + name: "login_method", + value: "qr" + ) %> + <% end %> +
+ <% end %> + <% end %> + +
+ <% if trade_republic_item.persisted? && trade_republic_item.pending_login_state.present? %> + <% if trade_republic_item.login_stage == "expired" %> +
+

<%= 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 %> +
+
diff --git a/app/views/trade_republic_items/_connection_success.html.erb b/app/views/trade_republic_items/_connection_success.html.erb new file mode 100644 index 000000000..00db4e343 --- /dev/null +++ b/app/views/trade_republic_items/_connection_success.html.erb @@ -0,0 +1,32 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("settings.providers.trade_republic_panel.connection_success.title")) %> + + <% dialog.with_body do %> +
+
+
+ <%= icon "check-circle", size: "lg", color: "success", class: "mt-0.5 shrink-0" %> +
+

+ <%= t("settings.providers.trade_republic_panel.connection_success.message") %> +

+

+ <%= t("settings.providers.trade_republic_panel.connection_success.description") %> +

+
+
+
+ +
+ <%= render DS::Button.new( + text: t("settings.providers.trade_republic_panel.connection_success.close"), + variant: :primary, + type: "button", + data: { action: "DS--dialog#close" } + ) %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/trade_republic_items/_trade_republic_item.html.erb b/app/views/trade_republic_items/_trade_republic_item.html.erb new file mode 100644 index 000000000..0569891bb --- /dev/null +++ b/app/views/trade_republic_items/_trade_republic_item.html.erb @@ -0,0 +1,150 @@ +<%# locals: (trade_republic_item:) %> + +<%= tag.div id: dom_id(trade_republic_item) do %> + <% unlinked_count = trade_republic_item.unlinked_accounts_count %> + + <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> + +
+ TR +
+ +
+
+ <%= tag.p trade_republic_item.institution_display_name, class: "font-medium text-primary" %> + <% if trade_republic_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+ <% if trade_republic_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif trade_republic_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".requires_update") %> +
+ <% elsif trade_republic_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: trade_republic_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <% 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 %> +
+
+ + <% if Current.user&.admin? %> +
+ <%= icon( + "refresh-cw", + as_button: true, + href: sync_trade_republic_item_path(trade_republic_item), + disabled: trade_republic_item.syncing? + ) %> + + <%= render DS::Menu.new do |menu| %> + <% if unlinked_count > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_accounts"), + icon: "settings", + href: setup_accounts_trade_republic_item_path(trade_republic_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: trade_republic_item_path(trade_republic_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(trade_republic_item.institution_display_name, high_severity: true) + ) %> + <% end %> +
+ <% end %> +
+ <% end %> + + <% unless trade_republic_item.scheduled_for_deletion? %> +
+ <% if trade_republic_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: trade_republic_item.accounts %> + <% end %> + + <% stats = trade_republic_item.syncs.ordered.first&.sync_stats || {} %> + <%= render ProviderSyncSummary.new(stats: stats, provider_item: trade_republic_item) %> + + <% quality = trade_republic_item.data_quality_summary %> + <% reconciliation = trade_republic_item.reconciliation_summary %> + <% expenses = trade_republic_item.expense_summary %> +
+
+

<%= 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.positions", count: quality[:positions]) %> + <%= t(".data_quality.unpriced", count: quality[:unpriced_positions]) %> + <%= t(".data_quality.events", count: quality[:events]) %> + <%= t(".data_quality.unknown", count: quality[:unknown_events]) %> +
+
+ <% expense_money = Money.new(expenses[:total].round(2), trade_republic_item.currency) %> + <%= t(".data_quality.expenses", count: expenses[:count], amount: format_money(expense_money)) %> + <%= t(".data_quality.reconciliation", matched: reconciliation.count { |check| check[:reconciled] }, total: reconciliation.size) %> +
+ <% recent_syncs = trade_republic_item.sync_history(limit: 3) %> + <% if recent_syncs.any? %> +
+

<%= 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 %> +
+ <% end %> +
+ + <% if Current.user&.admin? %> + <% if unlinked_count > 0 && trade_republic_item.accounts.empty? %> +
+

<%= 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 + ) %> +
+ <% elsif trade_republic_item.trade_republic_accounts.none? %> +
+

<%= t(".no_accounts_discovered") %>

+

<%= t(".no_accounts_discovered_description") %>

+
+ <% end %> + <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/trade_republic_items/select_existing_account.html.erb b/app/views/trade_republic_items/select_existing_account.html.erb new file mode 100644 index 000000000..ad8c2c880 --- /dev/null +++ b/app/views/trade_republic_items/select_existing_account.html.erb @@ -0,0 +1,39 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> + <% if @available_trade_republic_accounts.blank? %> +
+

<%= t(".no_accounts_available") %>

+
    +
  • <%= t(".run_sync_hint") %>
  • +
  • <%= t(".wait_for_sync") %>
  • +
+
+ <% else %> + <%= form_with url: link_existing_account_trade_republic_items_path, method: :post, data: { turbo_frame: "_top" }, class: "space-y-4" do %> + <%= hidden_field_tag :account_id, @account.id %> +
+ <% @available_trade_republic_accounts.each do |tr_account| %> + + <% end %> +
+ +
+ <%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %> + <%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %> +
+ <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/trade_republic_items/setup_accounts.html.erb b/app/views/trade_republic_items/setup_accounts.html.erb new file mode 100644 index 000000000..ec1510e19 --- /dev/null +++ b/app/views/trade_republic_items/setup_accounts.html.erb @@ -0,0 +1,170 @@ +<% content_for :title, t(".page_title") %> + +<%= render DS::Dialog.new(disable_click_outside: true) do |dialog| %> + <% dialog.with_header(title: t(".dialog_title")) do %> +
+ <%= icon "chart-line", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> +
+
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

<%= t(".info_box.title") %>

+
    +
  • <%= t(".info_box.items.item_1") %>
  • +
  • <%= t(".info_box.items.item_2") %>
  • +
  • <%= t(".info_box.items.item_3") %>
  • +
+
+
+
+ + <% if @waiting_for_sync %> +
+
+

<%= t(".status.fetching_accounts") %>

+
+
+ <%= render DS::Link.new( + text: t(".buttons.refresh"), + variant: "secondary", + icon: "refresh-cw", + href: setup_accounts_trade_republic_item_path(@trade_republic_item), + frame: "_top" + ) %> + <%= render DS::Link.new( + text: t(".buttons.cancel"), + variant: "ghost", + href: accounts_path, + frame: "_top" + ) %> +
+ <% elsif @no_accounts_found %> +
+ <%= icon "alert-circle", size: "lg", class: "text-warning" %> +

<%= t(".status.no_accounts_found_title") %>

+

<%= t(".status.no_accounts_found_description") %>

+
+
+ <%= render DS::Link.new( + text: t(".buttons.back_to_settings"), + variant: "secondary", + href: settings_providers_path, + frame: "_top" + ) %> +
+ <% else %> + <%= form_with url: complete_account_setup_trade_republic_item_path(@trade_republic_item), method: :post, data: { turbo_frame: "_top" } do %> + <% if @unlinked_accounts.any? %> +
+

<%= t(".available_accounts.title") %>

+ + <% @unlinked_accounts.each do |tr_account| %> +
+
+ + +
+
+ <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".buttons.create_selected_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1" + ) %> + <%= render DS::Link.new( + text: t(".buttons.cancel"), + variant: "secondary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% end %> + <% end %> + + <% if @unlinked_accounts.any? && @linkable_accounts.any? %> +
+

<%= t(".link_existing.description") %>

+
+ <% @unlinked_accounts.each do |tr_account| %> + <%= form_with url: link_existing_account_trade_republic_items_path, method: :post, data: { turbo_frame: "_top" } do |link_form| %> + <%= link_form.hidden_field :trade_republic_account_id, value: tr_account.id %> +

<%= tr_account.name %>

+
+ <%= link_form.select :account_id, + options_for_select(@linkable_accounts.map { |a| [ t(".link_existing.manual_account_option", name: a.name, balance: number_to_currency(a.balance, unit: Money::Currency.new(a.currency.presence || Current.family.currency).symbol)), a.id ] }), + { prompt: t(".link_existing.select_prompt") }, + { class: "bg-container border border-primary rounded px-2 py-1 text-sm text-primary flex-1 min-w-0", required: true } %> + <%= render DS::Button.new( + text: t(".buttons.link"), + variant: "secondary", + size: "sm", + type: "submit" + ) %> +
+ <% end %> + <% end %> +
+
+ <% end %> + + <% if @linked_accounts.any? %> +
"> +

<%= t(".linked_accounts.title") %>

+ <% @linked_accounts.each do |tr_account| %> +
+
+
+ <%= icon "check-circle", class: "text-success" %> +
+

<%= 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")) %>

+
+
+
+
+ <% end %> + + <% if @unlinked_accounts.blank? %> +
+ <%= render DS::Link.new( + text: t(".buttons.done"), + variant: "primary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% end %> +
+ <% end %> + <% end %> +
+ <% end %> +<% end %> diff --git a/config/locales/views/holdings/ca.yml b/config/locales/views/holdings/ca.yml index 899da5016..886617e2e 100644 --- a/config/locales/views/holdings/ca.yml +++ b/config/locales/views/holdings/ca.yml @@ -30,6 +30,9 @@ ca: shares: "%{qty} accions" unknown: "--" index: + positions: + one: "%{count} posició" + other: "%{count} posicions" average_cost: Cost mitjà holdings: Posicions name: Nom diff --git a/config/locales/views/holdings/de.yml b/config/locales/views/holdings/de.yml index 674f223da..8a5074058 100644 --- a/config/locales/views/holdings/de.yml +++ b/config/locales/views/holdings/de.yml @@ -44,6 +44,9 @@ de: name: Name new_holding: Neue Transaktion no_holdings: Keine Positionen vorhanden + positions: + one: "%{count} Position" + other: "%{count} Positionen" return: Gesamtrendite weight: Gewichtung missing_price_tooltip: diff --git a/config/locales/views/holdings/en.yml b/config/locales/views/holdings/en.yml index 824619c0a..3820fcf64 100644 --- a/config/locales/views/holdings/en.yml +++ b/config/locales/views/holdings/en.yml @@ -48,6 +48,9 @@ en: name: Name new_holding: New activity no_holdings: No holdings to show. + positions: + one: "%{count} position" + other: "%{count} positions" return: Total return weight: Weight missing_price_tooltip: diff --git a/config/locales/views/holdings/es.yml b/config/locales/views/holdings/es.yml index adbe4c963..d02bd988c 100644 --- a/config/locales/views/holdings/es.yml +++ b/config/locales/views/holdings/es.yml @@ -43,6 +43,9 @@ es: unknown: -- no_cost_basis: Sin base de costes index: + positions: + one: "%{count} posición" + other: "%{count} posiciones" average_cost: Costo promedio holdings: Posiciones name: Nombre diff --git a/config/locales/views/holdings/fr.yml b/config/locales/views/holdings/fr.yml index 08899780f..1d6d136d4 100644 --- a/config/locales/views/holdings/fr.yml +++ b/config/locales/views/holdings/fr.yml @@ -30,6 +30,9 @@ fr: shares: "%{qty} actions" unknown: "--" index: + positions: + one: "%{count} position" + other: "%{count} positions" average_cost: Coût moyen holdings: Holdings name: Nom diff --git a/config/locales/views/holdings/hu.yml b/config/locales/views/holdings/hu.yml index c685e9803..0346320e9 100644 --- a/config/locales/views/holdings/hu.yml +++ b/config/locales/views/holdings/hu.yml @@ -43,6 +43,9 @@ hu: unknown: "--" no_cost_basis: Nincs bekerülési érték index: + positions: + one: "%{count} pozíció" + other: "%{count} pozíció" average_cost: Átlagos bekerülési érték holdings: Pozíciók name: Név diff --git a/config/locales/views/holdings/it.yml b/config/locales/views/holdings/it.yml index d007dda8d..97f3bc2b0 100644 --- a/config/locales/views/holdings/it.yml +++ b/config/locales/views/holdings/it.yml @@ -43,6 +43,9 @@ it: unknown: "--" no_cost_basis: Nessun costo base index: + positions: + one: "%{count} posizione" + other: "%{count} posizioni" average_cost: Costo medio holdings: Portafoglio name: Nome diff --git a/config/locales/views/holdings/nb.yml b/config/locales/views/holdings/nb.yml index 67f0e68ef..66339a4d9 100644 --- a/config/locales/views/holdings/nb.yml +++ b/config/locales/views/holdings/nb.yml @@ -9,6 +9,9 @@ nb: per_share: per aksje shares: "%{qty} aksjer" index: + positions: + one: "%{count} posisjon" + other: "%{count} posisjoner" average_cost: Gjennomsnittlig kostnad holdings: Beholdninger name: Navn diff --git a/config/locales/views/holdings/nl.yml b/config/locales/views/holdings/nl.yml index 1a694db42..42e6be074 100644 --- a/config/locales/views/holdings/nl.yml +++ b/config/locales/views/holdings/nl.yml @@ -30,6 +30,9 @@ nl: unknown: "--" no_cost_basis: Geen kostprijs index: + positions: + one: "%{count} positie" + other: "%{count} posities" average_cost: Gemiddelde kostprijs holdings: Bezittingen name: Naam diff --git a/config/locales/views/holdings/pl.yml b/config/locales/views/holdings/pl.yml index c9e364c13..39032c6bc 100644 --- a/config/locales/views/holdings/pl.yml +++ b/config/locales/views/holdings/pl.yml @@ -41,6 +41,9 @@ pl: unknown: "--" no_cost_basis: Brak kosztu bazowego index: + positions: + one: "%{count} pozycja" + other: "%{count} pozycje" average_cost: Średni koszt holdings: Pozycje name: Nazwa diff --git a/config/locales/views/holdings/pt-BR.yml b/config/locales/views/holdings/pt-BR.yml index 39be1bcd4..86f79f47a 100644 --- a/config/locales/views/holdings/pt-BR.yml +++ b/config/locales/views/holdings/pt-BR.yml @@ -9,6 +9,9 @@ pt-BR: per_share: por ação shares: "%{qty} ações" index: + positions: + one: "%{count} posição" + other: "%{count} posições" average_cost: Custo médio holdings: Posições name: Nome diff --git a/config/locales/views/holdings/ro.yml b/config/locales/views/holdings/ro.yml index ecf996bfc..f4fc26ec7 100644 --- a/config/locales/views/holdings/ro.yml +++ b/config/locales/views/holdings/ro.yml @@ -9,6 +9,9 @@ ro: per_share: per acțiune shares: "%{qty} acțiuni" index: + positions: + one: "%{count} poziție" + other: "%{count} poziții" average_cost: Cost mediu holdings: Dețineri name: Nume diff --git a/config/locales/views/holdings/ru.yml b/config/locales/views/holdings/ru.yml index 67389bd75..5682ffb88 100644 --- a/config/locales/views/holdings/ru.yml +++ b/config/locales/views/holdings/ru.yml @@ -30,6 +30,9 @@ ru: shares: "%{qty} акций" unknown: "--" index: + positions: + one: "%{count} позиция" + other: "%{count} позиций" average_cost: Средняя стоимость holdings: Активы name: Название diff --git a/config/locales/views/holdings/tr.yml b/config/locales/views/holdings/tr.yml index e9910a631..7cd178468 100644 --- a/config/locales/views/holdings/tr.yml +++ b/config/locales/views/holdings/tr.yml @@ -30,6 +30,9 @@ tr: shares: "%{qty} adet hisse" unknown: "--" index: + positions: + one: "%{count} pozisyon" + other: "%{count} pozisyon" average_cost: Ortalama maliyet holdings: Varlıklar name: İsim diff --git a/config/locales/views/holdings/uk.yml b/config/locales/views/holdings/uk.yml index f069f331c..adfdb99ee 100644 --- a/config/locales/views/holdings/uk.yml +++ b/config/locales/views/holdings/uk.yml @@ -43,6 +43,9 @@ uk: unknown: "--" no_cost_basis: "Немає базової вартості" index: + positions: + one: "%{count} позиція" + other: "%{count} позицій" average_cost: "Середня вартість" holdings: "Активи" name: "Назва" diff --git a/config/locales/views/holdings/vi.yml b/config/locales/views/holdings/vi.yml index 216836479..2475b3106 100644 --- a/config/locales/views/holdings/vi.yml +++ b/config/locales/views/holdings/vi.yml @@ -43,6 +43,9 @@ vi: unknown: "--" no_cost_basis: Không có giá vốn index: + positions: + one: "%{count} vị thế" + other: "%{count} vị thế" average_cost: Giá trung bình holdings: Danh mục nắm giữ name: Tên diff --git a/config/locales/views/holdings/zh-CN.yml b/config/locales/views/holdings/zh-CN.yml index f0c6a38ef..ac8b5480b 100644 --- a/config/locales/views/holdings/zh-CN.yml +++ b/config/locales/views/holdings/zh-CN.yml @@ -43,6 +43,9 @@ zh-CN: unknown: -- no_cost_basis: 没有成本基础 index: + positions: + one: "%{count} 个持仓" + other: "%{count} 个持仓" average_cost: 平均成本 holdings: 持仓 name: 名称 diff --git a/config/locales/views/holdings/zh-TW.yml b/config/locales/views/holdings/zh-TW.yml index 5993af66e..87e155005 100644 --- a/config/locales/views/holdings/zh-TW.yml +++ b/config/locales/views/holdings/zh-TW.yml @@ -43,6 +43,9 @@ zh-TW: unknown: -- no_cost_basis: 沒有成本基礎 index: + positions: + one: "%{count} 個持倉" + other: "%{count} 個持倉" average_cost: 平均成本 holdings: 持股 name: 名稱 diff --git a/config/locales/views/settings/de.yml b/config/locales/views/settings/de.yml index c54df30e7..bbfa2afd7 100644 --- a/config/locales/views/settings/de.yml +++ b/config/locales/views/settings/de.yml @@ -478,6 +478,7 @@ de: snaptrade: Verbindet Depots über das Aggregationsnetz von SnapTrade. sophtron: Verbindet Banken und Versorger in den USA & Kanada. trading212: Synchronisiert dein Trading-212-Depot über einen API-Key mit Lesezugriff. + trade_republic: Synchronisiert dein Trade-Republic-Konto über einen sicheren Web-Login mit Lesezugriff. wise: Synchronisiert deine Wise-Guthaben in mehreren Währungen und internationale Überweisungen automatisch. trading212_panel: accounts_tab: Konten @@ -502,6 +503,59 @@ de: step_3: Füg beides unten ein, wähl Umgebung und Kontowährung und speichere. sync: Sync update_configuration: Konfiguration aktualisieren + trade_republic_panel: + encryption_warning: + title: Verschlüsselung ist nicht konfiguriert + message: Trade-Republic-Zugangsdaten und Sitzungsdaten können erst sicher gespeichert werden, wenn Active-Record-Verschlüsselung konfiguriert ist. + steps: + step_1: Gib die Telefonnummer und PIN deiner Trade-Republic-App ein und speichere sie. + step_2: Gib den Code deiner Authenticator-App ein, wenn du dazu aufgefordert wirst. + step_3: Bestätige den Login in deiner Trade-Republic-App, wenn du dazu aufgefordert wirst. + phone_number_label: Telefonnummer + phone_number_placeholder_new: "+49 170 1234567" + phone_number_placeholder_existing: Leer lassen, um die aktuelle Telefonnummer zu behalten + pin_label: PIN + pin_placeholder_new: PIN deiner Trade-Republic-App + pin_placeholder_existing: Leer lassen, um die aktuelle PIN zu behalten + currency_label: Kontowährung + connect: Einloggen + confirm_login: Login prüfen + complete_login: Fortfahren + authenticator_code_label: Authenticator-Code (falls angefordert) + authenticator_code_placeholder: 6-stelliger Code + push_login_notice: Bestätige jetzt den Login in deiner Trade-Republic-App. Sure prüft automatisch im Hintergrund. Du kannst den Status auch direkt selbst prüfen. + check_login_status: Status jetzt prüfen + authenticator_code_notice: Gib den Code deiner Authenticator-App ein. + approval_pending: Login gestartet. Bestätige ihn in der Trade-Republic-App; Sure prüft automatisch. + awaiting_confirmation: Bestätigung in der Trade-Republic-App ausstehend. + awaiting_authenticator_code: Authenticator-Code erforderlich. + login_expired: Die Bestätigung ist abgelaufen. + restart_login: Login erneut starten + connection_success: + title: Trade Republic erfolgreich verbunden + message: Deine Trade-Republic-Verbindung ist eingerichtet. + description: Sure synchronisiert deine Trade-Republic-Daten jetzt im Hintergrund. + close: Fertig + qr_login: Mit QR-Code einloggen + qr_cancel: QR-Login abbrechen + qr_back_to_credentials: Zurück zu Telefonnummer und PIN + qr_loading: QR-Code wird vorbereitet … + qr_instruction: Scanne den QR-Code mit deiner Trade-Republic-App und bestätige die Anmeldung. + qr_success: Anmeldung bestätigt. Sure lädt die Provider-Übersicht neu. + qr_error: Der QR-Login konnte nicht gestartet werden. Bitte versuche es erneut. + save_configuration: Speichern und Login starten + update_configuration: Konfiguration aktualisieren + sync: Sync + disconnect: Trade Republic trennen + disconnect_confirm: Trade Republic trennen? Deine synchronisierten Konten werden zu manuellen Konten. + status_configured_prefix: "Verbunden. Im Reiter" + accounts_tab: Konten + setup_accounts: Konten einrichten + accounts_discovered: + one: 1 Trade-Republic-Konto gefunden. + other: "%{count} Trade-Republic-Konten gefunden." + status_configured_suffix: verwaltest du die gefundenen Konten. + not_configured: Nicht eingerichtet. up_panel: step_1_html: Geh zu %{link} und erzeug einen persönlichen Zugriffstoken. step_2: Kopier deinen persönlichen Zugriffstoken. diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 941500da2..8af78d841 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -430,6 +430,7 @@ en: onchain_wallet: Track self-custody Bitcoin, EVM and Solana wallets from their public addresses. snaptrade: Connect brokerage accounts via the SnapTrade aggregation network. trading212: Sync your Trading 212 investment portfolio using a read-only API key. + trade_republic: Sync your Trade Republic investment account via secure read-only web login. ibkr: Sync Interactive Brokers investment accounts via Flex Query imports. questrade: Sync your Questrade investment accounts directly via the Questrade API. indexa_capital: Track your Indexa Capital automated investment portfolio. @@ -641,6 +642,59 @@ en: accounts_tab: Accounts status_configured_suffix: tab to manage discovered accounts. not_configured: Not configured. + trade_republic_panel: + encryption_warning: + title: Encryption is not configured + message: Trade Republic credentials and session data cannot be stored securely until Active Record Encryption is configured. + steps: + step_1: Enter the phone number and PIN you use for the Trade Republic app, then save. + step_2: Enter the code from your authenticator app when requested. + step_3: Approve the login in your Trade Republic app when prompted. + phone_number_label: Phone number + phone_number_placeholder_new: "+49 170 1234567" + phone_number_placeholder_existing: Leave blank to keep the current phone number + pin_label: PIN + pin_placeholder_new: Your Trade Republic app PIN + pin_placeholder_existing: Leave blank to keep the current PIN + currency_label: Account currency + connect: Log in + confirm_login: Check login + complete_login: Continue + authenticator_code_label: Authenticator code (if prompted) + authenticator_code_placeholder: "6-digit code" + push_login_notice: Approve the login in your Trade Republic app now. Sure checks automatically in the background. You can also check the status yourself. + check_login_status: Check status now + authenticator_code_notice: Enter the code from your authenticator app. + approval_pending: Login started. Approve it in the Trade Republic app; Sure is checking automatically. + awaiting_confirmation: Approval in the Trade Republic app is pending. + awaiting_authenticator_code: Authenticator code required. + login_expired: The approval request has expired. + restart_login: Start login again + connection_success: + title: Trade Republic connected successfully + message: Your Trade Republic connection is ready. + description: Sure is now syncing your Trade Republic data in the background. + close: Done + qr_login: Log in with QR code + qr_cancel: Cancel QR login + qr_back_to_credentials: Back to phone number and PIN + qr_loading: Preparing QR code … + qr_instruction: Scan the QR code with your Trade Republic app and approve the login. + qr_success: Login approved. Sure is refreshing the provider page. + qr_error: QR login could not be started. Please try again. + save_configuration: Save & Start Login + update_configuration: Update Configuration + sync: Sync + disconnect: Disconnect Trade Republic + disconnect_confirm: Disconnect Trade Republic? Your synced accounts will become manual accounts. + status_configured_prefix: "Connected. Visit the" + accounts_tab: Accounts + setup_accounts: Set up accounts + accounts_discovered: + one: 1 Trade Republic account discovered. + other: "%{count} Trade Republic accounts discovered." + status_configured_suffix: tab to manage discovered accounts. + not_configured: Not configured. trading212_panel: steps: step_1: Go to Trading 212 → Settings → API (web or mobile app). diff --git a/config/locales/views/trade_republic_items/ca.yml b/config/locales/views/trade_republic_items/ca.yml new file mode 100644 index 000000000..ceb726078 --- /dev/null +++ b/config/locales/views/trade_republic_items/ca.yml @@ -0,0 +1,106 @@ +--- +ca: + providers: + trade_republic: + name: Trade Republic + connection_description: "Connecta un compte d'inversió de Trade Republic mitjançant un inici de sessió web segur" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Compte de corretatge" + private_markets: "Mercats privats" + interest_products: "Productes d'interès" + crypto_wallet: "Cartera de criptomonedes" + trade_republic_item: + syncing: "S'està sincronitzant" + requires_update: "Cal tornar a autenticar-se" + error: "Error" + synced: "Sincronitzat fa %{time}. %{summary}." + setup_accounts: "Configura els comptes" + delete: "Suprimeix" + accounts_need_setup: "El compte necessita configuració" + accounts_need_setup_description: "El teu compte de Trade Republic s'ha d'enllaçar amb un compte de Sure." + no_accounts_discovered: "Encara no s'ha descobert cap compte de Trade Republic." + no_accounts_discovered_description: "Completa l'inici de sessió i executa una sincronització per descobrir el compte." + data_quality: + title: "Qualitat de les dades i detalls de sincronització" + repair: "Repara les dades importades" + positions: + one: "%{count} posició" + other: "%{count} posicions" + unpriced: + one: "%{count} sense preu actual" + other: "%{count} sense preu actual" + events: + one: "%{count} activitat" + other: "%{count} activitats" + unknown: + one: "%{count} activitat desconeguda" + other: "%{count} activitats desconegudes" + expenses: + one: "%{count} despesa durant els darrers 30 dies (%{amount})" + other: "%{count} despeses durant els darrers 30 dies (%{amount})" + reconciliation: "%{matched}/%{total} saldos de compte conciliats" + sync_history: Sincronitzacions recents + sync_status: + completed: Completada + failed: Fallida + pending: Pendent + syncing: Sincronitzant + stale: Obsoleta + setup_accounts: + page_title: "Configura el compte de Trade Republic" + subtitle: "Enllaça el teu compte d'inversió de Trade Republic." + available_accounts: + account_type_investment: "Cartera d'inversió" + account_type_cash: "Compte d'efectiu" + buttons: + refresh: "Actualitza" + cancel: "Cancel·la" + link: "Enllaça" + done: "Fet" + sync: + errors: + phone_number_missing: "Falta el número de telèfon de Trade Republic." + reauthentication_required: "La sessió de Trade Republic falta o ha caducat. Torna a connectar-la des d'Arranjament > Proveïdors." + status: + checking_credentials: "Comprovant les credencials de Trade Republic..." + importing_account: "Important el compte de Trade Republic..." + processing_activity: "Processant les posicions i l'activitat..." + calculating_balances: "Calculant els saldos..." + repair: + scheduled: "La reparació de les dades de Trade Republic s'ha programat." + select_existing_account: + title: "Enllaça el compte de Trade Republic" + link: "Enllaça" + cancel: "Cancel·la" + initiate_login: + verification_required: "Inicia la sessió i confirma-la mitjançant %{method}, després prem Confirma l'inici de sessió." + complete_login: + approval_pending: "Encara no s'ha confirmat. Aprova l'inici de sessió a l'aplicació de Trade Republic i torna-ho a provar." + success: "Trade Republic s'ha connectat correctament." + login_expired: "La sol·licitud d'inici de sessió ha caducat. Torna-ho a provar." + link_existing_account: + not_found: "Compte o configuració de Trade Republic no trobats." + success: "Enllaçat correctament amb el compte de Trade Republic." + complete_account_setup: + success: + one: "S'ha creat correctament %{count} compte de Trade Republic." + other: "S'han creat correctament %{count} comptes de Trade Republic." + none_created: "No s'ha creat cap compte." + partial_failure: + one: "No s'ha pogut completar un compte de Trade Republic seleccionat. Revisa el registre de depuració i torna-ho a provar." + other: "No s'han pogut completar %{count} comptes de Trade Republic seleccionats. Revisa el registre de depuració i torna-ho a provar." + activities: + labels: + contribution: Aportació + withdrawal: Retirada + interest: Interessos + dividend: Dividend + card_payment: Pagament amb targeta + cash_withdrawal: Retirada d'efectiu + card_fee: Comissió de targeta + card_refund: Reemborsament de targeta + tax_refund: Devolució d'impostos + buy: Compra + sell: Venda diff --git a/config/locales/views/trade_republic_items/de.yml b/config/locales/views/trade_republic_items/de.yml new file mode 100644 index 000000000..816e7d546 --- /dev/null +++ b/config/locales/views/trade_republic_items/de.yml @@ -0,0 +1,120 @@ +--- +de: + providers: + trade_republic: + name: Trade Republic + connection_description: "Verbinde ein Trade-Republic-Investmentkonto über eine sichere Web-Anmeldung" + institution_name: Trade Republic + trade_republic_items: + defaults: + name: Trade Republic + portfolio_categories: + brokerage: "Brokerage" + private_markets: "Private Märkte" + interest_products: "Zinsprodukte" + crypto_wallet: "Krypto-Wallet" + trade_republic_item: + syncing: "Wird synchronisiert" + requires_update: "Erneute Authentifizierung erforderlich" + error: "Fehler" + synced: "Vor %{time} synchronisiert. %{summary}." + setup_accounts: "Konten einrichten" + delete: "Löschen" + accounts_need_setup: "Konto muss eingerichtet werden" + accounts_need_setup_description: "Dein Trade-Republic-Konto muss mit einem Sure-Konto verknüpft werden." + no_accounts_discovered: "Noch kein Trade-Republic-Konto entdeckt." + no_accounts_discovered_description: "Schließe die Anmeldung ab und starte einen Sync, um dein Konto zu entdecken." + data_quality: + title: "Datenqualität und Sync-Details" + repair: "Importdaten reparieren" + positions: + one: "%{count} Position" + other: "%{count} Positionen" + unpriced: + one: "%{count} ohne aktuellen Kurs" + other: "%{count} ohne aktuellen Kurs" + events: + one: "%{count} Aktivität" + other: "%{count} Aktivitäten" + unknown: + one: "%{count} unbekannte Aktivität" + other: "%{count} unbekannte Aktivitäten" + expenses: + one: "%{count} Ausgabe in den letzten 30 Tagen (%{amount})" + other: "%{count} Ausgaben in den letzten 30 Tagen (%{amount})" + reconciliation: "%{matched}/%{total} Kontostände abgeglichen" + sync_history: "Letzte Syncs" + sync_status: + completed: Abgeschlossen + failed: Fehlgeschlagen + pending: Ausstehend + syncing: Wird synchronisiert + stale: Veraltet + setup_accounts: + page_title: "Trade-Republic-Konto einrichten" + subtitle: "Verknüpfe dein Trade-Republic-Investmentkonto." + available_accounts: + account_type_investment: "Investment-Portfolio" + account_type_cash: "Cash-Konto" + buttons: + refresh: "Aktualisieren" + cancel: "Abbrechen" + link: "Verknüpfen" + done: "Fertig" + sync: + errors: + phone_number_missing: "Die Telefonnummer für Trade Republic fehlt." + reauthentication_required: "Die Trade-Republic-Sitzung fehlt oder ist abgelaufen. Verbinde den Account unter Einstellungen > Anbieter erneut." + status: + checking_credentials: "Trade-Republic-Zugangsdaten werden geprüft..." + importing_account: "Trade-Republic-Konto wird importiert..." + processing_activity: "Positionen und Aktivitäten werden verarbeitet..." + calculating_balances: "Kontostände werden berechnet..." + repair: + scheduled: "Die Reparatur der Trade-Republic-Daten wurde eingeplant." + select_existing_account: + title: "Trade-Republic-Konto verknüpfen" + link: "Verknüpfen" + cancel: "Abbrechen" + initiate_login: + pin_required: "Gib deine Trade-Republic-PIN ein, um eine neue Anmeldung zu starten." + verification_required: "Anmeldung gestartet. Bestätige sie über %{method} und klicke anschließend auf Anmeldung bestätigen." + initiate_qr_login: + not_configured: "Trade Republic ist nicht konfiguriert." + poll_login: + no_pending_login: "Es ist keine Anmeldung ausstehend. Starte zuerst eine neue Anmeldung." + login_expired: "Die Anmeldeanfrage ist abgelaufen. Bitte starte sie erneut." + poll_qr_login: + no_pending_login: "Es ist keine QR-Anmeldung ausstehend." + success: "Trade Republic wurde erfolgreich verbunden." + complete_login: + approval_pending: "Noch nicht bestätigt. Bestätige die Anmeldung in der Trade-Republic-App und versuche es danach erneut." + still_pending: "Noch nicht bestätigt. Bestätige die Anmeldung in der Trade-Republic-App und versuche es danach erneut." + success: "Trade Republic wurde erfolgreich verbunden." + login_expired: "Die Anmeldeanfrage ist abgelaufen. Bitte starte sie erneut." + link_existing_account: + not_found: "Konto oder Trade-Republic-Konfiguration nicht gefunden." + success: "Erfolgreich mit dem Trade-Republic-Konto verknüpft." + processing_failed: "Das Konto wurde verknüpft, aber die erste Datenverarbeitung ist fehlgeschlagen. Ein Sync wurde geplant; prüfe das Debug-Log, falls die Daten unvollständig bleiben." + complete_account_setup: + none_selected: "Es wurden keine Konten ausgewählt." + partial_failure: + one: "Ein ausgewähltes Trade-Republic-Konto konnte nicht vollständig eingerichtet werden. Prüfe das Debug-Log und versuche es erneut." + other: "%{count} ausgewählte Trade-Republic-Konten konnten nicht vollständig eingerichtet werden. Prüfe das Debug-Log und versuche es erneut." + update: + pin_required: "Gib deine Trade-Republic-PIN ein, um die Verbindung erneut zu authentifizieren." + destroy: + unlink_failed: "Trade Republic konnte nicht vollständig getrennt werden. Die Verbindung bleibt für einen sicheren erneuten Versuch erhalten." + activities: + labels: + contribution: Einzahlung + withdrawal: Auszahlung + interest: Zinsen + dividend: Dividende + card_payment: Kartenzahlung + cash_withdrawal: Bargeldabhebung + card_fee: Kartengebühr + card_refund: Kartenrückerstattung + tax_refund: Steuerrückerstattung + buy: Kauf + sell: Verkauf diff --git a/config/locales/views/trade_republic_items/en.yml b/config/locales/views/trade_republic_items/en.yml new file mode 100644 index 000000000..8f3c1ad0c --- /dev/null +++ b/config/locales/views/trade_republic_items/en.yml @@ -0,0 +1,174 @@ +--- +en: + providers: + trade_republic: + name: Trade Republic + connection_description: Connect a Trade Republic investment account via secure web login + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: Brokerage + private_markets: Private Markets + interest_products: Interest Products + crypto_wallet: Crypto Wallet + defaults: + name: Trade Republic + trade_republic_item: + deletion_in_progress: Deletion in progress + syncing: Syncing + requires_update: Re-authentication required + error: Error + synced: Synced %{time} ago. %{summary}. + never_synced: Never synced. + setup_accounts: Set up accounts + delete: Delete + accounts_need_setup: Account needs setup + accounts_need_setup_description: Your Trade Republic account needs to be linked to a Sure account. + no_accounts_discovered: No Trade Republic account discovered yet. + no_accounts_discovered_description: Complete the login and run a sync to discover your account. + data_quality: + title: Data quality and sync details + repair: Repair imported data + positions: + one: "%{count} position" + other: "%{count} positions" + unpriced: + one: "%{count} without current price" + other: "%{count} without current price" + events: + one: "%{count} activity" + other: "%{count} activities" + unknown: + one: "%{count} unknown activity" + other: "%{count} unknown activities" + expenses: + one: "%{count} expense in the last 30 days (%{amount})" + other: "%{count} expenses in the last 30 days (%{amount})" + reconciliation: "%{matched}/%{total} account balances reconciled" + sync_history: Recent syncs + sync_status: + completed: Completed + failed: Failed + pending: Pending + syncing: Syncing + stale: Stale + setup_accounts: + page_title: Set Up Trade Republic Account + dialog_title: Set Up Your Trade Republic Account + subtitle: Link your Trade Republic investment account. + info_box: + title: Trade Republic Import + items: + item_1: Current positions with prices and quantities + item_2: Executed trades (buys and sells) + item_3: Deposits, withdrawals, and interest payouts + status: + fetching_accounts: Fetching account from Trade Republic... + no_accounts_found_title: No account found. + no_accounts_found_description: Sure could not find a Trade Republic account. Check that your session is still valid. + available_accounts: + title: Available account + account_type_investment: Investment portfolio + account_type_cash: Cash account + account_summary: "%{account_type} • Balance: %{balance}" + account_id: "Account ID: %{account_id}" + link_existing: + description: Or link the discovered Trade Republic account to an existing manual investment account. + manual_account_option: "%{name} (%{balance})" + select_prompt: Select an account... + linked_accounts: + title: Already linked + linked_to_html: "Linked to: %{account}" + buttons: + refresh: Refresh + cancel: Cancel + back_to_settings: Back to Settings + create_selected_accounts: Create selected accounts + link: Link + done: Done + sync: + errors: + phone_number_missing: Trade Republic phone number is missing. + reauthentication_required: Trade Republic session missing or expired. Re-connect from Settings > Providers. + status: + checking_credentials: Checking Trade Republic credentials... + importing_account: Importing Trade Republic account... + checking_configuration: Checking account configuration... + accounts_need_setup: + one: 1 Trade Republic account needs setup... + other: "%{count} Trade Republic accounts need setup..." + processing_activity: Processing positions and activity... + calculating_balances: Calculating balances... + repair: + scheduled: Trade Republic data repair scheduled. + sync_status: + no_accounts: No Trade Republic account discovered yet + all_linked: + one: 1 account linked + other: "%{count} accounts linked" + partial: "%{linked} linked, %{unlinked} need setup" + select_existing_account: + title: Link Trade Republic account + no_accounts_available: No unlinked Trade Republic accounts are available yet. + run_sync_hint: "Complete the login and run a sync from Settings > Providers." + wait_for_sync: Wait for the account discovery sync to finish. + balance: Balance + link: Link + cancel: Cancel + create: + success: Trade Republic saved. Confirm the login in the Trade Republic app to finish connecting. + update: + success: Successfully updated Trade Republic configuration. + pin_required: Enter your Trade Republic PIN to re-authenticate. + destroy: + success: Scheduled Trade Republic connection for deletion. + unlink_failed: Trade Republic could not be disconnected completely. The connection was kept so it can be retried safely. + select_accounts: + not_configured: Trade Republic is not configured. + initiate_login: + not_configured: Trade Republic is not configured. + pin_required: Enter your Trade Republic PIN to start a new login. + verification_required: "Login started. Confirm it via %{method}, then press Confirm login." + initiate_qr_login: + not_configured: Trade Republic is not configured. + poll_login: + no_pending_login: No login is currently pending. Start a new login first. + login_expired: The login request expired. Please start again. + poll_qr_login: + no_pending_login: No QR login is pending. + success: Trade Republic connected successfully. + complete_login: + no_pending_login: No login is currently pending. Start a new login first. + approval_pending: Not confirmed yet. Approve the login in the Trade Republic app, then try again. + still_pending: Not confirmed yet. Approve the login in the Trade Republic app, then try again. + success: Trade Republic connected successfully. + login_expired: The login request expired. Please start again. + link_existing_account: + not_found: Account or Trade Republic configuration not found. + only_manual_investment: Only manual investment accounts can be linked to Trade Republic. + already_linked: This Trade Republic account is already linked. + success: Successfully linked to Trade Republic account. + processing_failed: The account was linked, but its initial data processing failed. A sync has been scheduled; check the debug log if it remains incomplete. + failed: Failed to link Trade Republic account. + complete_account_setup: + success: + one: Successfully created %{count} Trade Republic account. + other: Successfully created %{count} Trade Republic accounts. + none_selected: No accounts were selected. + none_created: No accounts were created. + partial_failure: + one: One selected Trade Republic account could not be completed. Check the debug log and try again. + other: "%{count} selected Trade Republic accounts could not be completed. Check the debug log and try again." + activities: + labels: + contribution: Contribution + withdrawal: Withdrawal + interest: Interest + dividend: Dividend + card_payment: Card payment + cash_withdrawal: Cash withdrawal + card_fee: Card fee + card_refund: Card refund + tax_refund: Tax refund + buy: Buy + sell: Sell diff --git a/config/locales/views/trade_republic_items/es.yml b/config/locales/views/trade_republic_items/es.yml new file mode 100644 index 000000000..233d87181 --- /dev/null +++ b/config/locales/views/trade_republic_items/es.yml @@ -0,0 +1,106 @@ +--- +es: + providers: + trade_republic: + name: Trade Republic + connection_description: "Conecta una cuenta de inversión de Trade Republic mediante un inicio de sesión web seguro" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Cuenta de corretaje" + private_markets: "Mercados privados" + interest_products: "Productos de intereses" + crypto_wallet: "Cartera de criptomonedas" + trade_republic_item: + syncing: "Sincronizando" + requires_update: "Se requiere volver a autenticarse" + error: "Error" + synced: "Sincronizado hace %{time}. %{summary}." + setup_accounts: "Configurar cuentas" + delete: "Eliminar" + accounts_need_setup: "La cuenta necesita configuración" + accounts_need_setup_description: "Tu cuenta de Trade Republic debe vincularse a una cuenta de Sure." + no_accounts_discovered: "Aún no se ha descubierto ninguna cuenta de Trade Republic." + no_accounts_discovered_description: "Completa el inicio de sesión y ejecuta una sincronización para descubrir tu cuenta." + data_quality: + title: "Calidad de los datos y detalles de sincronización" + repair: "Reparar datos importados" + positions: + one: "%{count} posición" + other: "%{count} posiciones" + unpriced: + one: "%{count} sin precio actual" + other: "%{count} sin precio actual" + events: + one: "%{count} actividad" + other: "%{count} actividades" + unknown: + one: "%{count} actividad desconocida" + other: "%{count} actividades desconocidas" + expenses: + one: "%{count} gasto en los últimos 30 días (%{amount})" + other: "%{count} gastos en los últimos 30 días (%{amount})" + reconciliation: "%{matched}/%{total} saldos de cuenta conciliados" + sync_history: Sincronizaciones recientes + sync_status: + completed: Completada + failed: Fallida + pending: Pendiente + syncing: Sincronizando + stale: Desactualizada + setup_accounts: + page_title: "Configurar cuenta de Trade Republic" + subtitle: "Vincula tu cuenta de inversión de Trade Republic." + available_accounts: + account_type_investment: "Cartera de inversión" + account_type_cash: "Cuenta de efectivo" + buttons: + refresh: "Actualizar" + cancel: "Cancelar" + link: "Vincular" + done: "Listo" + sync: + errors: + phone_number_missing: "Falta el número de teléfono de Trade Republic." + reauthentication_required: "La sesión de Trade Republic falta o ha caducado. Vuelve a conectarla desde Ajustes > Proveedores." + status: + checking_credentials: "Comprobando las credenciales de Trade Republic..." + importing_account: "Importando la cuenta de Trade Republic..." + processing_activity: "Procesando posiciones y actividad..." + calculating_balances: "Calculando saldos..." + repair: + scheduled: "Se ha programado la reparación de los datos de Trade Republic." + select_existing_account: + title: "Vincular cuenta de Trade Republic" + link: "Vincular" + cancel: "Cancelar" + initiate_login: + verification_required: "Inicio de sesión iniciado. Confírmalo mediante %{method} y pulsa Confirmar inicio de sesión." + complete_login: + approval_pending: "Aún no se ha confirmado. Aprueba el inicio de sesión en la aplicación de Trade Republic y vuelve a intentarlo." + success: "Trade Republic se ha conectado correctamente." + login_expired: "La solicitud de inicio de sesión ha caducado. Vuelve a iniciarla." + link_existing_account: + not_found: "No se ha encontrado la cuenta o configuración de Trade Republic." + success: "Cuenta vinculada correctamente a Trade Republic." + complete_account_setup: + success: + one: "Se ha creado correctamente %{count} cuenta de Trade Republic." + other: "Se han creado correctamente %{count} cuentas de Trade Republic." + none_created: "No se ha creado ninguna cuenta." + partial_failure: + one: "No se pudo completar una cuenta de Trade Republic seleccionada. Revisa el registro de depuración y vuelve a intentarlo." + other: "No se pudieron completar %{count} cuentas de Trade Republic seleccionadas. Revisa el registro de depuración y vuelve a intentarlo." + activities: + labels: + contribution: Aportación + withdrawal: Retirada + interest: Intereses + dividend: Dividendo + card_payment: Pago con tarjeta + cash_withdrawal: Retirada de efectivo + card_fee: Comisión de tarjeta + card_refund: Reembolso de tarjeta + tax_refund: Devolución de impuestos + buy: Compra + sell: Venta diff --git a/config/locales/views/trade_republic_items/fr.yml b/config/locales/views/trade_republic_items/fr.yml new file mode 100644 index 000000000..2b43d93d4 --- /dev/null +++ b/config/locales/views/trade_republic_items/fr.yml @@ -0,0 +1,106 @@ +--- +fr: + providers: + trade_republic: + name: Trade Republic + connection_description: "Connecter un compte d’investissement Trade Republic via une connexion web sécurisée" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Courtage" + private_markets: "Marchés privés" + interest_products: "Produits rémunérés" + crypto_wallet: "Portefeuille crypto" + trade_republic_item: + syncing: "Synchronisation en cours" + requires_update: "Réauthentification requise" + error: "Erreur" + synced: "Synchronisé il y a %{time}. %{summary}." + setup_accounts: "Configurer les comptes" + delete: "Supprimer" + accounts_need_setup: "Compte à configurer" + accounts_need_setup_description: "Votre compte Trade Republic doit être lié à un compte Sure." + no_accounts_discovered: "Aucun compte Trade Republic découvert pour le moment." + no_accounts_discovered_description: "Terminez la connexion et lancez une synchronisation pour découvrir votre compte." + data_quality: + title: "Qualité des données et détails de synchronisation" + repair: "Réparer les données importées" + positions: + one: "%{count} position" + other: "%{count} positions" + unpriced: + one: "%{count} sans prix actuel" + other: "%{count} sans prix actuel" + events: + one: "%{count} activité" + other: "%{count} activités" + unknown: + one: "%{count} activité inconnue" + other: "%{count} activités inconnues" + expenses: + one: "%{count} dépense au cours des 30 derniers jours (%{amount})" + other: "%{count} dépenses au cours des 30 derniers jours (%{amount})" + reconciliation: "%{matched}/%{total} soldes de comptes rapprochés" + sync_history: Synchronisations récentes + sync_status: + completed: Terminée + failed: Échouée + pending: En attente + syncing: Synchronisation + stale: Obsolète + setup_accounts: + page_title: "Configurer le compte Trade Republic" + subtitle: "Lier votre compte d’investissement Trade Republic." + available_accounts: + account_type_investment: "Investment portfolio" + account_type_cash: "Cash account" + buttons: + refresh: "Actualiser" + cancel: "Annuler" + link: "Lier" + done: "Terminé" + sync: + errors: + phone_number_missing: "Le numéro de téléphone Trade Republic est manquant." + reauthentication_required: "La session Trade Republic est absente ou expirée. Reconnectez-la depuis Paramètres > Fournisseurs." + status: + checking_credentials: "Vérification des identifiants Trade Republic..." + importing_account: "Importation du compte Trade Republic..." + processing_activity: "Traitement des positions et activités..." + calculating_balances: "Calcul des soldes..." + repair: + scheduled: "La réparation des données Trade Republic a été planifiée." + select_existing_account: + title: "Lier un compte Trade Republic" + link: "Lier" + cancel: "Annuler" + initiate_login: + verification_required: "Connexion démarrée. Confirmez-la via %{method}, puis cliquez sur Confirmer la connexion." + complete_login: + approval_pending: "Pas encore confirmé. Approuvez la connexion dans l’application Trade Republic, puis réessayez." + success: "Trade Republic a été connecté avec succès." + login_expired: "La demande de connexion a expiré. Veuillez recommencer." + link_existing_account: + not_found: "Compte ou configuration Trade Republic introuvable." + success: "Compte lié avec succès à Trade Republic." + complete_account_setup: + success: + one: "%{count} compte Trade Republic a été créé avec succès." + other: "%{count} comptes Trade Republic ont été créés avec succès." + none_created: "Aucun compte n'a été créé." + partial_failure: + one: "Un compte Trade Republic sélectionné n'a pas pu être entièrement configuré. Consultez le journal de débogage et réessayez." + other: "%{count} comptes Trade Republic sélectionnés n'ont pas pu être entièrement configurés. Consultez le journal de débogage et réessayez." + activities: + labels: + contribution: Versement + withdrawal: Retrait + interest: Intérêts + dividend: Dividende + card_payment: Paiement par carte + cash_withdrawal: Retrait d'espèces + card_fee: Frais de carte + card_refund: Remboursement par carte + tax_refund: Remboursement d'impôt + buy: Achat + sell: Vente diff --git a/config/locales/views/trade_republic_items/hu.yml b/config/locales/views/trade_republic_items/hu.yml new file mode 100644 index 000000000..b99135b9d --- /dev/null +++ b/config/locales/views/trade_republic_items/hu.yml @@ -0,0 +1,80 @@ +--- +hu: + providers: + trade_republic: + name: Trade Republic + connection_description: "Trade Republic befektetési számla összekapcsolása biztonságos webes bejelentkezéssel" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Brókerszámla" + private_markets: "Privát piacok" + interest_products: "Kamatozó termékek" + crypto_wallet: "Kriptotárca" + trade_republic_item: + syncing: "Szinkronizálás" + requires_update: "Újrahitelesítés szükséges" + error: "Hiba" + synced: "Szinkronizálva: %{time}. %{summary}." + setup_accounts: "Fiókok beállítása" + delete: "Törlés" + accounts_need_setup: "A fiókot be kell állítani" + accounts_need_setup_description: "A Trade Republic-fiókodat egy Sure-fiókhoz kell kapcsolni." + no_accounts_discovered: "Még nem található Trade Republic-fiók." + no_accounts_discovered_description: "Fejezd be a bejelentkezést, majd indíts szinkronizálást a fiók felderítéséhez." + data_quality: + title: "Adatminőség és szinkronizálási részletek" + repair: "Importált adatok javítása" + setup_accounts: + page_title: "Trade Republic-fiók beállítása" + subtitle: "Kapcsold össze a Trade Republic befektetési számládat." + buttons: + refresh: "Frissítés" + cancel: "Mégse" + link: "Kapcsolás" + done: "Kész" + sync: + errors: + phone_number_missing: "Hiányzik a Trade Republic telefonszáma." + reauthentication_required: "A Trade Republic-munkamenet hiányzik vagy lejárt. Csatlakozz újra a Beállítások > Szolgáltatók részen." + status: + checking_credentials: "Trade Republic-hitelesítő adatok ellenőrzése..." + importing_account: "Trade Republic-fiók importálása..." + processing_activity: "Pozíciók és tevékenységek feldolgozása..." + calculating_balances: "Egyenlegek számítása..." + repair: + scheduled: "A Trade Republic-adatok javítása ütemezve." + select_existing_account: + title: "Trade Republic-fiók kapcsolása" + link: "Kapcsolás" + cancel: "Mégse" + initiate_login: + verification_required: "A bejelentkezés elindult. Erősítsd meg a(z) %{method} segítségével, majd nyomd meg a bejelentkezés megerősítését." + complete_login: + approval_pending: "Még nincs megerősítve. Hagyd jóvá a bejelentkezést a Trade Republic alkalmazásban, majd próbáld újra." + success: "A Trade Republic sikeresen csatlakoztatva." + login_expired: "A bejelentkezési kérelem lejárt. Indítsd újra." + link_existing_account: + not_found: "A fiók vagy a Trade Republic konfiguráció nem található." + success: "A fiók sikeresen összekapcsolva a Trade Republic szolgáltatással." + complete_account_setup: + success: + one: "Sikeresen létrejött %{count} Trade Republic-fiók." + other: "Sikeresen létrejött %{count} Trade Republic-fiók." + none_created: "Nem jött létre fiók." + partial_failure: + one: "Egy kiválasztott Trade Republic-fiók beállítása nem fejeződött be. Ellenőrizd a hibakeresési naplót, majd próbáld újra." + other: "%{count} kiválasztott Trade Republic-fiók beállítása nem fejeződött be. Ellenőrizd a hibakeresési naplót, majd próbáld újra." + activities: + labels: + contribution: Befizetés + withdrawal: Kifizetés + interest: Kamat + dividend: Osztalék + card_payment: Kártyás fizetés + cash_withdrawal: Készpénzfelvétel + card_fee: Kártyadíj + card_refund: Kártyás visszatérítés + tax_refund: Adó-visszatérítés + buy: Vétel + sell: Eladás diff --git a/config/locales/views/trade_republic_items/it.yml b/config/locales/views/trade_republic_items/it.yml new file mode 100644 index 000000000..27ef9d821 --- /dev/null +++ b/config/locales/views/trade_republic_items/it.yml @@ -0,0 +1,80 @@ +--- +it: + providers: + trade_republic: + name: Trade Republic + connection_description: "Collega un conto d’investimento Trade Republic tramite accesso web sicuro" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Conto di intermediazione" + private_markets: "Mercati privati" + interest_products: "Prodotti fruttiferi" + crypto_wallet: "Portafoglio crypto" + trade_republic_item: + syncing: "Sincronizzazione" + requires_update: "Nuova autenticazione richiesta" + error: "Errore" + synced: "Sincronizzato %{time} fa. %{summary}." + setup_accounts: "Configura i conti" + delete: "Elimina" + accounts_need_setup: "Conto da configurare" + accounts_need_setup_description: "Il tuo conto Trade Republic deve essere collegato a un conto Sure." + no_accounts_discovered: "Nessun conto Trade Republic ancora rilevato." + no_accounts_discovered_description: "Completa l’accesso ed esegui una sincronizzazione per rilevare il conto." + data_quality: + title: "Qualità dei dati e dettagli della sincronizzazione" + repair: "Ripara i dati importati" + setup_accounts: + page_title: "Configura il conto Trade Republic" + subtitle: "Collega il tuo conto d’investimento Trade Republic." + buttons: + refresh: "Aggiorna" + cancel: "Annulla" + link: "Collega" + done: "Fine" + sync: + errors: + phone_number_missing: "Manca il numero di telefono Trade Republic." + reauthentication_required: "La sessione Trade Republic manca o è scaduta. Ricollegala da Impostazioni > Provider." + status: + checking_credentials: "Verifica delle credenziali Trade Republic..." + importing_account: "Importazione del conto Trade Republic..." + processing_activity: "Elaborazione di posizioni e attività..." + calculating_balances: "Calcolo dei saldi..." + repair: + scheduled: "Riparazione dei dati Trade Republic pianificata." + select_existing_account: + title: "Collega il conto Trade Republic" + link: "Collega" + cancel: "Annulla" + initiate_login: + verification_required: "Accesso avviato. Confermalo tramite %{method}, poi premi Conferma accesso." + complete_login: + approval_pending: "Non ancora confermato. Approva l’accesso nell’app Trade Republic e riprova." + success: "Trade Republic collegato correttamente." + login_expired: "La richiesta di accesso è scaduta. Riavvia la procedura." + link_existing_account: + not_found: "Conto o configurazione Trade Republic non trovati." + success: "Conto collegato correttamente a Trade Republic." + complete_account_setup: + success: + one: "È stato creato correttamente %{count} conto Trade Republic." + other: "Sono stati creati correttamente %{count} conti Trade Republic." + none_created: "Non è stato creato alcun conto." + partial_failure: + one: "Non è stato possibile completare un conto Trade Republic selezionato. Controlla il registro di debug e riprova." + other: "Non è stato possibile completare %{count} conti Trade Republic selezionati. Controlla il registro di debug e riprova." + activities: + labels: + contribution: Versamento + withdrawal: Prelievo + interest: Interessi + dividend: Dividendo + card_payment: Pagamento con carta + cash_withdrawal: Prelievo di contanti + card_fee: Commissione carta + card_refund: Rimborso carta + tax_refund: Rimborso fiscale + buy: Acquisto + sell: Vendita diff --git a/config/locales/views/trade_republic_items/nb.yml b/config/locales/views/trade_republic_items/nb.yml new file mode 100644 index 000000000..4ceadbce4 --- /dev/null +++ b/config/locales/views/trade_republic_items/nb.yml @@ -0,0 +1,80 @@ +--- +nb: + providers: + trade_republic: + name: Trade Republic + connection_description: "Koble til en Trade Republic-investeringskonto via sikker nettpålogging" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Meglerkonto" + private_markets: "Private markeder" + interest_products: "Renteprodukter" + crypto_wallet: "Kryptolommebok" + trade_republic_item: + syncing: "Synkroniserer" + requires_update: "Ny autentisering kreves" + error: "Feil" + synced: "Synkronisert for %{time} siden. %{summary}." + setup_accounts: "Konfigurer kontoer" + delete: "Slett" + accounts_need_setup: "Konto må konfigureres" + accounts_need_setup_description: "Trade Republic-kontoen din må kobles til en Sure-konto." + no_accounts_discovered: "Ingen Trade Republic-konto er oppdaget ennå." + no_accounts_discovered_description: "Fullfør påloggingen og kjør en synkronisering for å finne kontoen." + data_quality: + title: "Datakvalitet og synkroniseringsdetaljer" + repair: "Reparer importerte data" + setup_accounts: + page_title: "Konfigurer Trade Republic-konto" + subtitle: "Koble til Trade Republic-investeringskontoen din." + buttons: + refresh: "Oppdater" + cancel: "Avbryt" + link: "Koble til" + done: "Ferdig" + sync: + errors: + phone_number_missing: "Trade Republic-telefonnummer mangler." + reauthentication_required: "Trade Republic-økten mangler eller har utløpt. Koble til på nytt fra Innstillinger > Tilbydere." + status: + checking_credentials: "Sjekker Trade Republic-legitimasjon..." + importing_account: "Importerer Trade Republic-konto..." + processing_activity: "Behandler posisjoner og aktivitet..." + calculating_balances: "Beregner saldoer..." + repair: + scheduled: "Reparasjon av Trade Republic-data er planlagt." + select_existing_account: + title: "Koble til Trade Republic-konto" + link: "Koble til" + cancel: "Avbryt" + initiate_login: + verification_required: "Pålogging startet. Bekreft via %{method}, og trykk deretter på Bekreft pålogging." + complete_login: + approval_pending: "Ikke bekreftet ennå. Godkjenn påloggingen i Trade Republic-appen og prøv igjen." + success: "Trade Republic ble koblet til." + login_expired: "Påloggingsforespørselen utløp. Start på nytt." + link_existing_account: + not_found: "Fant ikke konto eller Trade Republic-konfigurasjon." + success: "Kontoen ble koblet til Trade Republic." + complete_account_setup: + success: + one: "%{count} Trade Republic-konto ble opprettet." + other: "%{count} Trade Republic-kontoer ble opprettet." + none_created: "Ingen kontoer ble opprettet." + partial_failure: + one: "Én valgt Trade Republic-konto kunne ikke fullføres. Sjekk feilsøkingsloggen og prøv igjen." + other: "%{count} valgte Trade Republic-kontoer kunne ikke fullføres. Sjekk feilsøkingsloggen og prøv igjen." + activities: + labels: + contribution: Innskudd + withdrawal: Uttak + interest: Rente + dividend: Utbytte + card_payment: Kortbetaling + cash_withdrawal: Kontantuttak + card_fee: Kortgebyr + card_refund: Kortrefusjon + tax_refund: Skatterefusjon + buy: Kjøp + sell: Salg diff --git a/config/locales/views/trade_republic_items/nl.yml b/config/locales/views/trade_republic_items/nl.yml new file mode 100644 index 000000000..9a0603c99 --- /dev/null +++ b/config/locales/views/trade_republic_items/nl.yml @@ -0,0 +1,80 @@ +--- +nl: + providers: + trade_republic: + name: Trade Republic + connection_description: "Verbind een Trade Republic-beleggingsrekening via een veilige webaanmelding" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Brokeragerekening" + private_markets: "Privémarkten" + interest_products: "Renteproducten" + crypto_wallet: "Cryptowallet" + trade_republic_item: + syncing: "Bezig met synchroniseren" + requires_update: "Opnieuw authenticeren vereist" + error: "Fout" + synced: "Gesynchroniseerd %{time} geleden. %{summary}." + setup_accounts: "Rekeningen instellen" + delete: "Verwijderen" + accounts_need_setup: "Rekening moet worden ingesteld" + accounts_need_setup_description: "Je Trade Republic-rekening moet aan een Sure-rekening worden gekoppeld." + no_accounts_discovered: "Nog geen Trade Republic-rekening gevonden." + no_accounts_discovered_description: "Voltooi de aanmelding en voer een synchronisatie uit om je rekening te vinden." + data_quality: + title: "Datakwaliteit en synchronisatiedetails" + repair: "Geïmporteerde gegevens herstellen" + setup_accounts: + page_title: "Trade Republic-rekening instellen" + subtitle: "Koppel je Trade Republic-beleggingsrekening." + buttons: + refresh: "Vernieuwen" + cancel: "Annuleren" + link: "Koppelen" + done: "Gereed" + sync: + errors: + phone_number_missing: "Het Trade Republic-telefoonnummer ontbreekt." + reauthentication_required: "De Trade Republic-sessie ontbreekt of is verlopen. Verbind opnieuw via Instellingen > Providers." + status: + checking_credentials: "Trade Republic-inloggegevens controleren..." + importing_account: "Trade Republic-rekening importeren..." + processing_activity: "Posities en activiteiten verwerken..." + calculating_balances: "Saldi berekenen..." + repair: + scheduled: "Herstel van Trade Republic-gegevens gepland." + select_existing_account: + title: "Trade Republic-rekening koppelen" + link: "Koppelen" + cancel: "Annuleren" + initiate_login: + verification_required: "Aanmelding gestart. Bevestig via %{method} en klik daarna op Aanmelding bevestigen." + complete_login: + approval_pending: "Nog niet bevestigd. Keur de aanmelding goed in de Trade Republic-app en probeer opnieuw." + success: "Trade Republic is succesvol verbonden." + login_expired: "De aanmeldingsaanvraag is verlopen. Start opnieuw." + link_existing_account: + not_found: "Rekening of Trade Republic-configuratie niet gevonden." + success: "Rekening succesvol aan Trade Republic gekoppeld." + complete_account_setup: + success: + one: "%{count} Trade Republic-rekening is aangemaakt." + other: "%{count} Trade Republic-rekeningen zijn aangemaakt." + none_created: "Er zijn geen rekeningen aangemaakt." + partial_failure: + one: "Eén geselecteerde Trade Republic-rekening kon niet volledig worden ingesteld. Controleer het debuglogboek en probeer het opnieuw." + other: "%{count} geselecteerde Trade Republic-rekeningen konden niet volledig worden ingesteld. Controleer het debuglogboek en probeer het opnieuw." + activities: + labels: + contribution: Storting + withdrawal: Opname + interest: Rente + dividend: Dividend + card_payment: Kaartbetaling + cash_withdrawal: Geldopname + card_fee: Kaartkosten + card_refund: Kaartterugbetaling + tax_refund: Belastingteruggaaf + buy: Koop + sell: Verkoop diff --git a/config/locales/views/trade_republic_items/pl.yml b/config/locales/views/trade_republic_items/pl.yml new file mode 100644 index 000000000..e49b0b848 --- /dev/null +++ b/config/locales/views/trade_republic_items/pl.yml @@ -0,0 +1,80 @@ +--- +pl: + providers: + trade_republic: + name: Trade Republic + connection_description: "Połącz rachunek inwestycyjny Trade Republic przez bezpieczne logowanie internetowe" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Rachunek maklerski" + private_markets: "Rynki prywatne" + interest_products: "Produkty oprocentowane" + crypto_wallet: "Portfel kryptowalut" + trade_republic_item: + syncing: "Synchronizowanie" + requires_update: "Wymagana ponowna autoryzacja" + error: "Błąd" + synced: "Zsynchronizowano %{time} temu. %{summary}." + setup_accounts: "Skonfiguruj rachunki" + delete: "Usuń" + accounts_need_setup: "Rachunek wymaga konfiguracji" + accounts_need_setup_description: "Twój rachunek Trade Republic musi zostać połączony z rachunkiem Sure." + no_accounts_discovered: "Nie wykryto jeszcze rachunku Trade Republic." + no_accounts_discovered_description: "Zakończ logowanie i uruchom synchronizację, aby wykryć rachunek." + data_quality: + title: "Jakość danych i szczegóły synchronizacji" + repair: "Napraw zaimportowane dane" + setup_accounts: + page_title: "Skonfiguruj rachunek Trade Republic" + subtitle: "Połącz swój rachunek inwestycyjny Trade Republic." + buttons: + refresh: "Odśwież" + cancel: "Anuluj" + link: "Połącz" + done: "Gotowe" + sync: + errors: + phone_number_missing: "Brakuje numeru telefonu Trade Republic." + reauthentication_required: "Sesja Trade Republic nie istnieje lub wygasła. Połącz ponownie w Ustawienia > Dostawcy." + status: + checking_credentials: "Sprawdzanie danych logowania Trade Republic..." + importing_account: "Importowanie rachunku Trade Republic..." + processing_activity: "Przetwarzanie pozycji i aktywności..." + calculating_balances: "Obliczanie sald..." + repair: + scheduled: "Zaplanowano naprawę danych Trade Republic." + select_existing_account: + title: "Połącz rachunek Trade Republic" + link: "Połącz" + cancel: "Anuluj" + initiate_login: + verification_required: "Logowanie rozpoczęte. Potwierdź je przez %{method}, a następnie kliknij Potwierdź logowanie." + complete_login: + approval_pending: "Jeszcze nie potwierdzono. Zatwierdź logowanie w aplikacji Trade Republic i spróbuj ponownie." + success: "Trade Republic połączono pomyślnie." + login_expired: "Żądanie logowania wygasło. Rozpocznij ponownie." + link_existing_account: + not_found: "Nie znaleziono rachunku ani konfiguracji Trade Republic." + success: "Pomyślnie połączono rachunek z Trade Republic." + complete_account_setup: + success: + one: "Pomyślnie utworzono %{count} konto Trade Republic." + other: "Pomyślnie utworzono %{count} kont Trade Republic." + none_created: "Nie utworzono żadnych kont." + partial_failure: + one: "Nie udało się w pełni skonfigurować jednego wybranego konta Trade Republic. Sprawdź dziennik debugowania i spróbuj ponownie." + other: "Nie udało się w pełni skonfigurować %{count} wybranych kont Trade Republic. Sprawdź dziennik debugowania i spróbuj ponownie." + activities: + labels: + contribution: Wpłata + withdrawal: Wypłata + interest: Odsetki + dividend: Dywidenda + card_payment: Płatność kartą + cash_withdrawal: Wypłata gotówki + card_fee: Opłata za kartę + card_refund: Zwrot na kartę + tax_refund: Zwrot podatku + buy: Kupno + sell: Sprzedaż diff --git a/config/locales/views/trade_republic_items/pt-BR.yml b/config/locales/views/trade_republic_items/pt-BR.yml new file mode 100644 index 000000000..7d05e75fe --- /dev/null +++ b/config/locales/views/trade_republic_items/pt-BR.yml @@ -0,0 +1,80 @@ +--- +pt-BR: + providers: + trade_republic: + name: Trade Republic + connection_description: "Conecte uma conta de investimentos da Trade Republic por meio de login web seguro" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Conta de corretora" + private_markets: "Mercados privados" + interest_products: "Produtos de juros" + crypto_wallet: "Carteira de criptomoedas" + trade_republic_item: + syncing: "Sincronizando" + requires_update: "É necessário reautenticar" + error: "Erro" + synced: "Sincronizado há %{time}. %{summary}." + setup_accounts: "Configurar contas" + delete: "Excluir" + accounts_need_setup: "A conta precisa ser configurada" + accounts_need_setup_description: "Sua conta da Trade Republic precisa ser vinculada a uma conta Sure." + no_accounts_discovered: "Nenhuma conta da Trade Republic foi descoberta ainda." + no_accounts_discovered_description: "Conclua o login e execute uma sincronização para descobrir sua conta." + data_quality: + title: "Qualidade dos dados e detalhes da sincronização" + repair: "Reparar dados importados" + setup_accounts: + page_title: "Configurar conta da Trade Republic" + subtitle: "Vincule sua conta de investimentos da Trade Republic." + buttons: + refresh: "Atualizar" + cancel: "Cancelar" + link: "Vincular" + done: "Concluído" + sync: + errors: + phone_number_missing: "O número de telefone da Trade Republic está ausente." + reauthentication_required: "A sessão da Trade Republic está ausente ou expirou. Reconecte em Configurações > Provedores." + status: + checking_credentials: "Verificando as credenciais da Trade Republic..." + importing_account: "Importando a conta da Trade Republic..." + processing_activity: "Processando posições e atividades..." + calculating_balances: "Calculando saldos..." + repair: + scheduled: "Reparo dos dados da Trade Republic agendado." + select_existing_account: + title: "Vincular conta da Trade Republic" + link: "Vincular" + cancel: "Cancelar" + initiate_login: + verification_required: "Login iniciado. Confirme por %{method} e pressione Confirmar login." + complete_login: + approval_pending: "Ainda não confirmado. Aprove o login no aplicativo da Trade Republic e tente novamente." + success: "Trade Republic conectada com sucesso." + login_expired: "A solicitação de login expirou. Inicie novamente." + link_existing_account: + not_found: "Conta ou configuração da Trade Republic não encontrada." + success: "Conta vinculada com sucesso à Trade Republic." + complete_account_setup: + success: + one: "Foi criada com sucesso %{count} conta da Trade Republic." + other: "Foram criadas com sucesso %{count} contas da Trade Republic." + none_created: "Nenhuma conta foi criada." + partial_failure: + one: "Não foi possível concluir uma conta selecionada da Trade Republic. Verifique o log de depuração e tente novamente." + other: "Não foi possível concluir %{count} contas selecionadas da Trade Republic. Verifique o log de depuração e tente novamente." + activities: + labels: + contribution: Aporte + withdrawal: Retirada + interest: Juros + dividend: Dividendo + card_payment: Pagamento com cartão + cash_withdrawal: Saque em dinheiro + card_fee: Tarifa do cartão + card_refund: Reembolso no cartão + tax_refund: Restituição de imposto + buy: Compra + sell: Venda diff --git a/config/locales/views/trade_republic_items/ro.yml b/config/locales/views/trade_republic_items/ro.yml new file mode 100644 index 000000000..107c9d557 --- /dev/null +++ b/config/locales/views/trade_republic_items/ro.yml @@ -0,0 +1,82 @@ +--- +ro: + providers: + trade_republic: + name: Trade Republic + connection_description: "Conectează un cont de investiții Trade Republic prin autentificare web securizată" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Cont de brokeraj" + private_markets: "Piețe private" + interest_products: "Produse cu dobândă" + crypto_wallet: "Portofel crypto" + trade_republic_item: + syncing: "Se sincronizează" + requires_update: "Este necesară reautentificarea" + error: "Eroare" + synced: "Sincronizat acum %{time}. %{summary}." + setup_accounts: "Configurează conturile" + delete: "Șterge" + accounts_need_setup: "Contul necesită configurare" + accounts_need_setup_description: "Contul tău Trade Republic trebuie asociat unui cont Sure." + no_accounts_discovered: "Niciun cont Trade Republic nu a fost descoperit încă." + no_accounts_discovered_description: "Finalizează autentificarea și rulează o sincronizare pentru a descoperi contul." + data_quality: + title: "Calitatea datelor și detalii de sincronizare" + repair: "Repară datele importate" + setup_accounts: + page_title: "Configurează contul Trade Republic" + subtitle: "Asociază contul tău de investiții Trade Republic." + buttons: + refresh: "Reîmprospătează" + cancel: "Anulează" + link: "Asociază" + done: "Gata" + sync: + errors: + phone_number_missing: "Lipsește numărul de telefon Trade Republic." + reauthentication_required: "Sesiunea Trade Republic lipsește sau a expirat. Reconectează din Setări > Furnizori." + status: + checking_credentials: "Se verifică datele de autentificare Trade Republic..." + importing_account: "Se importă contul Trade Republic..." + processing_activity: "Se procesează pozițiile și activitatea..." + calculating_balances: "Se calculează soldurile..." + repair: + scheduled: "Repararea datelor Trade Republic a fost programată." + select_existing_account: + title: "Asociază un cont Trade Republic" + link: "Asociază" + cancel: "Anulează" + initiate_login: + verification_required: "Autentificarea a început. Confirmă prin %{method}, apoi apasă Confirmă autentificarea." + complete_login: + approval_pending: "Nu a fost confirmat încă. Aprobă autentificarea în aplicația Trade Republic și încearcă din nou." + success: "Trade Republic a fost conectat cu succes." + login_expired: "Solicitarea de autentificare a expirat. Începe din nou." + link_existing_account: + not_found: "Contul sau configurația Trade Republic nu a fost găsită." + success: "Cont asociat cu succes la Trade Republic." + complete_account_setup: + success: + one: "A fost creat cu succes %{count} cont Trade Republic." + few: "Au fost create cu succes %{count} conturi Trade Republic." + other: "Au fost create cu succes %{count} de conturi Trade Republic." + none_created: "Nu a fost creat niciun cont." + partial_failure: + one: "Un cont Trade Republic selectat nu a putut fi configurat complet. Verifică jurnalul de depanare și încearcă din nou." + few: "%{count} conturi Trade Republic selectate nu au putut fi configurate complet. Verifică jurnalul de depanare și încearcă din nou." + other: "%{count} de conturi Trade Republic selectate nu au putut fi configurate complet. Verifică jurnalul de depanare și încearcă din nou." + activities: + labels: + contribution: Contribuție + withdrawal: Retragere + interest: Dobândă + dividend: Dividend + card_payment: Plată cu cardul + cash_withdrawal: Retragere numerar + card_fee: Comision card + card_refund: Rambursare pe card + tax_refund: Rambursare fiscală + buy: Cumpărare + sell: Vânzare diff --git a/config/locales/views/trade_republic_items/ru.yml b/config/locales/views/trade_republic_items/ru.yml new file mode 100644 index 000000000..f9092fd99 --- /dev/null +++ b/config/locales/views/trade_republic_items/ru.yml @@ -0,0 +1,80 @@ +--- +ru: + providers: + trade_republic: + name: Trade Republic + connection_description: "Подключите инвестиционный счёт Trade Republic через безопасный веб-вход" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Брокерский счёт" + private_markets: "Частные рынки" + interest_products: "Процентные продукты" + crypto_wallet: "Криптокошелёк" + trade_republic_item: + syncing: "Синхронизация" + requires_update: "Требуется повторная аутентификация" + error: "Ошибка" + synced: "Синхронизировано %{time} назад. %{summary}." + setup_accounts: "Настроить счета" + delete: "Удалить" + accounts_need_setup: "Требуется настройка счёта" + accounts_need_setup_description: "Ваш счёт Trade Republic нужно связать со счётом Sure." + no_accounts_discovered: "Счёт Trade Republic ещё не найден." + no_accounts_discovered_description: "Завершите вход и запустите синхронизацию, чтобы найти счёт." + data_quality: + title: "Качество данных и сведения о синхронизации" + repair: "Исправить импортированные данные" + setup_accounts: + page_title: "Настроить счёт Trade Republic" + subtitle: "Свяжите инвестиционный счёт Trade Republic." + buttons: + refresh: "Обновить" + cancel: "Отмена" + link: "Связать" + done: "Готово" + sync: + errors: + phone_number_missing: "Не указан номер телефона Trade Republic." + reauthentication_required: "Сеанс Trade Republic отсутствует или истёк. Подключитесь заново в разделе Настройки > Провайдеры." + status: + checking_credentials: "Проверка учётных данных Trade Republic..." + importing_account: "Импорт счёта Trade Republic..." + processing_activity: "Обработка позиций и операций..." + calculating_balances: "Расчёт балансов..." + repair: + scheduled: "Исправление данных Trade Republic запланировано." + select_existing_account: + title: "Связать счёт Trade Republic" + link: "Связать" + cancel: "Отмена" + initiate_login: + verification_required: "Вход начат. Подтвердите его через %{method}, затем нажмите «Подтвердить вход»." + complete_login: + approval_pending: "Ещё не подтверждено. Одобрите вход в приложении Trade Republic и повторите попытку." + success: "Trade Republic успешно подключён." + login_expired: "Запрос на вход истёк. Начните заново." + link_existing_account: + not_found: "Счёт или конфигурация Trade Republic не найдены." + success: "Счёт успешно связан с Trade Republic." + complete_account_setup: + success: + one: "Успешно создан %{count} счёт Trade Republic." + other: "Успешно создано %{count} счетов Trade Republic." + none_created: "Счета не созданы." + partial_failure: + one: "Не удалось полностью настроить один выбранный счёт Trade Republic. Проверьте журнал отладки и повторите попытку." + other: "Не удалось полностью настроить %{count} выбранных счетов Trade Republic. Проверьте журнал отладки и повторите попытку." + activities: + labels: + contribution: Пополнение + withdrawal: Вывод средств + interest: Проценты + dividend: Дивиденды + card_payment: Оплата картой + cash_withdrawal: Снятие наличных + card_fee: Комиссия по карте + card_refund: Возврат на карту + tax_refund: Налоговый возврат + buy: Покупка + sell: Продажа diff --git a/config/locales/views/trade_republic_items/tr.yml b/config/locales/views/trade_republic_items/tr.yml new file mode 100644 index 000000000..9e24c8656 --- /dev/null +++ b/config/locales/views/trade_republic_items/tr.yml @@ -0,0 +1,106 @@ +--- +tr: + providers: + trade_republic: + name: Trade Republic + connection_description: "Güvenli web girişiyle bir Trade Republic yatırım hesabı bağlayın" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Aracı kurum hesabı" + private_markets: "Özel piyasalar" + interest_products: "Faiz ürünleri" + crypto_wallet: "Kripto cüzdanı" + trade_republic_item: + syncing: "Senkronize ediliyor" + requires_update: "Yeniden kimlik doğrulama gerekli" + error: "Hata" + synced: "%{time} önce senkronize edildi. %{summary}." + setup_accounts: "Hesapları ayarla" + delete: "Sil" + accounts_need_setup: "Hesap kurulumu gerekli" + accounts_need_setup_description: "Trade Republic hesabınız bir Sure hesabına bağlanmalıdır." + no_accounts_discovered: "Henüz Trade Republic hesabı keşfedilmedi." + no_accounts_discovered_description: "Hesabınızı keşfetmek için girişi tamamlayıp senkronizasyon çalıştırın." + data_quality: + title: "Veri kalitesi ve senkronizasyon ayrıntıları" + repair: "İçe aktarılan verileri onar" + positions: + one: "%{count} pozisyon" + other: "%{count} pozisyon" + unpriced: + one: "%{count} güncel fiyatı olmayan pozisyon" + other: "%{count} güncel fiyatı olmayan pozisyon" + events: + one: "%{count} işlem" + other: "%{count} işlem" + unknown: + one: "%{count} bilinmeyen işlem" + other: "%{count} bilinmeyen işlem" + expenses: + one: "Son 30 günde %{count} gider (%{amount})" + other: "Son 30 günde %{count} gider (%{amount})" + reconciliation: "%{matched}/%{total} hesap bakiyesi mutabık" + sync_history: Son senkronizasyonlar + sync_status: + completed: Tamamlandı + failed: Başarısız + pending: Beklemede + syncing: Senkronize ediliyor + stale: Güncel değil + setup_accounts: + page_title: "Trade Republic hesabını ayarla" + subtitle: "Trade Republic yatırım hesabınızı bağlayın." + available_accounts: + account_type_investment: "Yatırım portföyü" + account_type_cash: "Nakit hesabı" + buttons: + refresh: "Yenile" + cancel: "İptal" + link: "Bağla" + done: "Bitti" + sync: + errors: + phone_number_missing: "Trade Republic telefon numarası eksik." + reauthentication_required: "Trade Republic oturumu eksik veya süresi dolmuş. Ayarlar > Sağlayıcılar bölümünden yeniden bağlanın." + status: + checking_credentials: "Trade Republic kimlik bilgileri kontrol ediliyor..." + importing_account: "Trade Republic hesabı içe aktarılıyor..." + processing_activity: "Pozisyonlar ve işlemler işleniyor..." + calculating_balances: "Bakiyeler hesaplanıyor..." + repair: + scheduled: "Trade Republic veri onarımı planlandı." + select_existing_account: + title: "Trade Republic hesabını bağla" + link: "Bağla" + cancel: "İptal" + initiate_login: + verification_required: "Giriş başlatıldı. %{method} ile onaylayın, ardından Girişi onayla düğmesine basın." + complete_login: + approval_pending: "Henüz onaylanmadı. Trade Republic uygulamasında girişi onaylayıp tekrar deneyin." + success: "Trade Republic başarıyla bağlandı." + login_expired: "Giriş isteğinin süresi doldu. Yeniden başlatın." + link_existing_account: + not_found: "Hesap veya Trade Republic yapılandırması bulunamadı." + success: "Hesap Trade Republic'e başarıyla bağlandı." + complete_account_setup: + success: + one: "%{count} Trade Republic hesabı başarıyla oluşturuldu." + other: "%{count} Trade Republic hesabı başarıyla oluşturuldu." + none_created: "Hiçbir hesap oluşturulmadı." + partial_failure: + one: "Seçilen %{count} Trade Republic hesabı tamamlanamadı. Hata ayıklama günlüğünü kontrol edip tekrar deneyin." + other: "Seçilen %{count} Trade Republic hesabı tamamlanamadı. Hata ayıklama günlüğünü kontrol edip tekrar deneyin." + activities: + labels: + contribution: Para yatırma + withdrawal: Para çekme + interest: Faiz + dividend: Temettü + card_payment: Kart ödemesi + cash_withdrawal: Nakit çekme + card_fee: Kart ücreti + card_refund: Kart iadesi + tax_refund: Vergi iadesi + buy: Alış + sell: Satış diff --git a/config/locales/views/trade_republic_items/uk.yml b/config/locales/views/trade_republic_items/uk.yml new file mode 100644 index 000000000..65db766b1 --- /dev/null +++ b/config/locales/views/trade_republic_items/uk.yml @@ -0,0 +1,120 @@ +--- +uk: + providers: + trade_republic: + name: Trade Republic + connection_description: "Підключіть інвестиційний рахунок Trade Republic через безпечний веб-вхід" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Брокерський рахунок" + private_markets: "Приватні ринки" + interest_products: "Процентні продукти" + crypto_wallet: "Криптогаманець" + trade_republic_item: + syncing: "Синхронізація" + requires_update: "Потрібна повторна автентифікація" + error: "Помилка" + synced: "Синхронізовано %{time} тому. %{summary}." + setup_accounts: "Налаштувати рахунки" + delete: "Видалити" + accounts_need_setup: "Рахунок потребує налаштування" + accounts_need_setup_description: "Ваш рахунок Trade Republic потрібно пов’язати з рахунком Sure." + no_accounts_discovered: "Рахунок Trade Republic ще не знайдено." + no_accounts_discovered_description: "Завершіть вхід і запустіть синхронізацію, щоб знайти рахунок." + data_quality: + title: "Якість даних і відомості про синхронізацію" + repair: "Відновити імпортовані дані" + positions: + one: "%{count} позиція" + few: "%{count} позиції" + many: "%{count} позицій" + other: "%{count} позиції" + unpriced: + one: "%{count} позиція без поточної ціни" + few: "%{count} позиції без поточної ціни" + many: "%{count} позицій без поточної ціни" + other: "%{count} позиції без поточної ціни" + events: + one: "%{count} операція" + few: "%{count} операції" + many: "%{count} операцій" + other: "%{count} операції" + unknown: + one: "%{count} невідома операція" + few: "%{count} невідомі операції" + many: "%{count} невідомих операцій" + other: "%{count} невідомі операції" + expenses: + one: "%{count} витрата за останні 30 днів (%{amount})" + few: "%{count} витрати за останні 30 днів (%{amount})" + many: "%{count} витрат за останні 30 днів (%{amount})" + other: "%{count} витрати за останні 30 днів (%{amount})" + reconciliation: "%{matched}/%{total} балансів рахунків узгоджено" + sync_history: Останні синхронізації + sync_status: + completed: Завершено + failed: Помилка + pending: Очікує + syncing: Синхронізується + stale: Застаріло + setup_accounts: + page_title: "Налаштувати рахунок Trade Republic" + subtitle: "Пов’яжіть інвестиційний рахунок Trade Republic." + available_accounts: + account_type_investment: "Інвестиційний портфель" + account_type_cash: "Грошовий рахунок" + buttons: + refresh: "Оновити" + cancel: "Скасувати" + link: "Пов’язати" + done: "Готово" + sync: + errors: + phone_number_missing: "Відсутній номер телефону Trade Republic." + reauthentication_required: "Сеанс Trade Republic відсутній або завершився. Підключіться знову в Налаштування > Постачальники." + status: + checking_credentials: "Перевірка облікових даних Trade Republic..." + importing_account: "Імпорт рахунку Trade Republic..." + processing_activity: "Обробка позицій і операцій..." + calculating_balances: "Обчислення балансів..." + repair: + scheduled: "Відновлення даних Trade Republic заплановано." + select_existing_account: + title: "Пов’язати рахунок Trade Republic" + link: "Пов’язати" + cancel: "Скасувати" + initiate_login: + verification_required: "Вхід розпочато. Підтвердьте через %{method}, потім натисніть Підтвердити вхід." + complete_login: + approval_pending: "Ще не підтверджено. Схваліть вхід у застосунку Trade Republic і спробуйте ще раз." + success: "Trade Republic успішно підключено." + login_expired: "Запит на вхід завершився. Почніть знову." + link_existing_account: + not_found: "Рахунок або конфігурацію Trade Republic не знайдено." + success: "Рахунок успішно пов’язано з Trade Republic." + complete_account_setup: + success: + one: "Успішно створено %{count} рахунок Trade Republic." + few: "Успішно створено %{count} рахунки Trade Republic." + many: "Успішно створено %{count} рахунків Trade Republic." + other: "Успішно створено %{count} рахунку Trade Republic." + none_created: "Жодного рахунку не створено." + partial_failure: + one: "Не вдалося повністю налаштувати %{count} вибраний рахунок Trade Republic. Перевірте журнал налагодження та повторіть спробу." + few: "Не вдалося повністю налаштувати %{count} вибрані рахунки Trade Republic. Перевірте журнал налагодження та повторіть спробу." + many: "Не вдалося повністю налаштувати %{count} вибраних рахунків Trade Republic. Перевірте журнал налагодження та повторіть спробу." + other: "Не вдалося повністю налаштувати %{count} вибраного рахунку Trade Republic. Перевірте журнал налагодження та повторіть спробу." + activities: + labels: + contribution: Поповнення + withdrawal: Виведення коштів + interest: Відсотки + dividend: Дивіденд + card_payment: Оплата карткою + cash_withdrawal: Зняття готівки + card_fee: Комісія за картку + card_refund: Повернення на картку + tax_refund: Повернення податку + buy: Купівля + sell: Продаж diff --git a/config/locales/views/trade_republic_items/vi.yml b/config/locales/views/trade_republic_items/vi.yml new file mode 100644 index 000000000..d6712e884 --- /dev/null +++ b/config/locales/views/trade_republic_items/vi.yml @@ -0,0 +1,106 @@ +--- +vi: + providers: + trade_republic: + name: Trade Republic + connection_description: "Kết nối tài khoản đầu tư Trade Republic bằng đăng nhập web an toàn" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "Tài khoản môi giới" + private_markets: "Thị trường tư nhân" + interest_products: "Sản phẩm lãi suất" + crypto_wallet: "Ví tiền mã hóa" + trade_republic_item: + syncing: "Đang đồng bộ" + requires_update: "Cần xác thực lại" + error: "Lỗi" + synced: "Đã đồng bộ %{time} trước. %{summary}." + setup_accounts: "Thiết lập tài khoản" + delete: "Xóa" + accounts_need_setup: "Tài khoản cần được thiết lập" + accounts_need_setup_description: "Tài khoản Trade Republic của bạn cần được liên kết với tài khoản Sure." + no_accounts_discovered: "Chưa phát hiện tài khoản Trade Republic." + no_accounts_discovered_description: "Hoàn tất đăng nhập và chạy đồng bộ để phát hiện tài khoản." + data_quality: + title: "Chất lượng dữ liệu và chi tiết đồng bộ" + repair: "Sửa dữ liệu đã nhập" + positions: + one: "%{count} vị thế" + other: "%{count} vị thế" + unpriced: + one: "%{count} chưa có giá hiện tại" + other: "%{count} chưa có giá hiện tại" + events: + one: "%{count} hoạt động" + other: "%{count} hoạt động" + unknown: + one: "%{count} hoạt động không xác định" + other: "%{count} hoạt động không xác định" + expenses: + one: "%{count} khoản chi trong 30 ngày qua (%{amount})" + other: "%{count} khoản chi trong 30 ngày qua (%{amount})" + reconciliation: "%{matched}/%{total} số dư tài khoản đã được đối soát" + sync_history: Các lần đồng bộ gần đây + sync_status: + completed: Hoàn tất + failed: Thất bại + pending: Đang chờ + syncing: Đang đồng bộ + stale: Cũ + setup_accounts: + page_title: "Thiết lập tài khoản Trade Republic" + subtitle: "Liên kết tài khoản đầu tư Trade Republic." + available_accounts: + account_type_investment: "Investment portfolio" + account_type_cash: "Cash account" + buttons: + refresh: "Làm mới" + cancel: "Hủy" + link: "Liên kết" + done: "Xong" + sync: + errors: + phone_number_missing: "Thiếu số điện thoại Trade Republic." + reauthentication_required: "Phiên Trade Republic bị thiếu hoặc đã hết hạn. Kết nối lại từ Cài đặt > Nhà cung cấp." + status: + checking_credentials: "Đang kiểm tra thông tin xác thực Trade Republic..." + importing_account: "Đang nhập tài khoản Trade Republic..." + processing_activity: "Đang xử lý vị thế và hoạt động..." + calculating_balances: "Đang tính số dư..." + repair: + scheduled: "Đã lên lịch sửa dữ liệu Trade Republic." + select_existing_account: + title: "Liên kết tài khoản Trade Republic" + link: "Liên kết" + cancel: "Hủy" + initiate_login: + verification_required: "Đã bắt đầu đăng nhập. Xác nhận qua %{method}, sau đó nhấn Xác nhận đăng nhập." + complete_login: + approval_pending: "Chưa được xác nhận. Hãy phê duyệt đăng nhập trong ứng dụng Trade Republic rồi thử lại." + success: "Đã kết nối Trade Republic thành công." + login_expired: "Yêu cầu đăng nhập đã hết hạn. Hãy bắt đầu lại." + link_existing_account: + not_found: "Không tìm thấy tài khoản hoặc cấu hình Trade Republic." + success: "Đã liên kết tài khoản với Trade Republic." + complete_account_setup: + success: + one: "Đã tạo thành công %{count} tài khoản Trade Republic." + other: "Đã tạo thành công %{count} tài khoản Trade Republic." + none_created: "Không có tài khoản nào được tạo." + partial_failure: + one: "Không thể hoàn tất một tài khoản Trade Republic đã chọn. Kiểm tra nhật ký gỡ lỗi và thử lại." + other: "Không thể hoàn tất %{count} tài khoản Trade Republic đã chọn. Kiểm tra nhật ký gỡ lỗi và thử lại." + activities: + labels: + contribution: Nạp tiền + withdrawal: Rút tiền + interest: Tiền lãi + dividend: Cổ tức + card_payment: Thanh toán bằng thẻ + cash_withdrawal: Rút tiền mặt + card_fee: Phí thẻ + card_refund: Hoàn tiền thẻ + tax_refund: Hoàn thuế + buy: Mua + sell: Bán diff --git a/config/locales/views/trade_republic_items/zh-CN.yml b/config/locales/views/trade_republic_items/zh-CN.yml new file mode 100644 index 000000000..08ee9dac6 --- /dev/null +++ b/config/locales/views/trade_republic_items/zh-CN.yml @@ -0,0 +1,106 @@ +--- +zh-CN: + providers: + trade_republic: + name: Trade Republic + connection_description: "通过安全的网页登录连接 Trade Republic 投资账户" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "券商账户" + private_markets: "私募市场" + interest_products: "计息产品" + crypto_wallet: "加密货币钱包" + trade_republic_item: + syncing: "正在同步" + requires_update: "需要重新认证" + error: "错误" + synced: "%{time}前同步。%{summary}。" + setup_accounts: "设置账户" + delete: "删除" + accounts_need_setup: "账户需要设置" + accounts_need_setup_description: "您的 Trade Republic 账户需要关联到 Sure 账户。" + no_accounts_discovered: "尚未发现 Trade Republic 账户。" + no_accounts_discovered_description: "完成登录并运行同步以发现您的账户。" + data_quality: + title: "数据质量和同步详情" + repair: "修复导入的数据" + positions: + one: "%{count} 个持仓" + other: "%{count} 个持仓" + unpriced: + one: "%{count} 个没有当前价格" + other: "%{count} 个没有当前价格" + events: + one: "%{count} 条活动" + other: "%{count} 条活动" + unknown: + one: "%{count} 条未知活动" + other: "%{count} 条未知活动" + expenses: + one: "过去 30 天 %{count} 笔支出(%{amount})" + other: "过去 30 天 %{count} 笔支出(%{amount})" + reconciliation: "%{matched}/%{total} 个账户余额已核对" + sync_history: 最近同步 + sync_status: + completed: 已完成 + failed: 失败 + pending: 待处理 + syncing: 同步中 + stale: 已过期 + setup_accounts: + page_title: "设置 Trade Republic 账户" + subtitle: "关联您的 Trade Republic 投资账户。" + available_accounts: + account_type_investment: "Investment portfolio" + account_type_cash: "Cash account" + buttons: + refresh: "刷新" + cancel: "取消" + link: "关联" + done: "完成" + sync: + errors: + phone_number_missing: "缺少 Trade Republic 电话号码。" + reauthentication_required: "Trade Republic 会话缺失或已过期。请从设置 > 提供商重新连接。" + status: + checking_credentials: "正在检查 Trade Republic 凭据..." + importing_account: "正在导入 Trade Republic 账户..." + processing_activity: "正在处理持仓和活动..." + calculating_balances: "正在计算余额..." + repair: + scheduled: "已安排修复 Trade Republic 数据。" + select_existing_account: + title: "关联 Trade Republic 账户" + link: "关联" + cancel: "取消" + initiate_login: + verification_required: "登录已开始。请通过 %{method} 确认,然后点击确认登录。" + complete_login: + approval_pending: "尚未确认。请在 Trade Republic 应用中批准登录,然后重试。" + success: "Trade Republic 已成功连接。" + login_expired: "登录请求已过期。请重新开始。" + link_existing_account: + not_found: "未找到账户或 Trade Republic 配置。" + success: "已成功关联 Trade Republic 账户。" + complete_account_setup: + success: + one: "已成功创建 %{count} 个 Trade Republic 账户。" + other: "已成功创建 %{count} 个 Trade Republic 账户。" + none_created: "未创建任何账户。" + partial_failure: + one: "无法完全设置所选的一个 Trade Republic 账户。请检查调试日志并重试。" + other: "无法完全设置所选的 %{count} 个 Trade Republic 账户。请检查调试日志并重试。" + activities: + labels: + contribution: 入金 + withdrawal: 出金 + interest: 利息 + dividend: 股息 + card_payment: 刷卡支付 + cash_withdrawal: 现金取款 + card_fee: 卡费 + card_refund: 卡退款 + tax_refund: 退税 + buy: 买入 + sell: 卖出 diff --git a/config/locales/views/trade_republic_items/zh-TW.yml b/config/locales/views/trade_republic_items/zh-TW.yml new file mode 100644 index 000000000..5c244f435 --- /dev/null +++ b/config/locales/views/trade_republic_items/zh-TW.yml @@ -0,0 +1,106 @@ +--- +zh-TW: + providers: + trade_republic: + name: Trade Republic + connection_description: "透過安全的網頁登入連結 Trade Republic 投資帳戶" + institution_name: Trade Republic + trade_republic_items: + portfolio_categories: + brokerage: "經紀帳戶" + private_markets: "私人市場" + interest_products: "計息產品" + crypto_wallet: "加密貨幣錢包" + trade_republic_item: + syncing: "正在同步" + requires_update: "需要重新驗證" + error: "錯誤" + synced: "%{time}前同步。%{summary}。" + setup_accounts: "設定帳戶" + delete: "刪除" + accounts_need_setup: "帳戶需要設定" + accounts_need_setup_description: "您的 Trade Republic 帳戶需要連結至 Sure 帳戶。" + no_accounts_discovered: "尚未發現 Trade Republic 帳戶。" + no_accounts_discovered_description: "完成登入並執行同步以發現您的帳戶。" + data_quality: + title: "資料品質與同步詳細資料" + repair: "修復匯入的資料" + positions: + one: "%{count} 個持倉" + other: "%{count} 個持倉" + unpriced: + one: "%{count} 個沒有目前價格" + other: "%{count} 個沒有目前價格" + events: + one: "%{count} 筆活動" + other: "%{count} 筆活動" + unknown: + one: "%{count} 筆未知活動" + other: "%{count} 筆未知活動" + expenses: + one: "過去 30 天 %{count} 筆支出(%{amount})" + other: "過去 30 天 %{count} 筆支出(%{amount})" + reconciliation: "%{matched}/%{total} 個帳戶餘額已核對" + sync_history: 最近同步 + sync_status: + completed: 已完成 + failed: 失敗 + pending: 待處理 + syncing: 同步中 + stale: 已過期 + setup_accounts: + page_title: "設定 Trade Republic 帳戶" + subtitle: "連結您的 Trade Republic 投資帳戶。" + available_accounts: + account_type_investment: "Investment portfolio" + account_type_cash: "Cash account" + buttons: + refresh: "重新整理" + cancel: "取消" + link: "連結" + done: "完成" + sync: + errors: + phone_number_missing: "缺少 Trade Republic 電話號碼。" + reauthentication_required: "Trade Republic 工作階段遺失或已過期。請從設定 > 供應商重新連線。" + status: + checking_credentials: "正在檢查 Trade Republic 認證資訊..." + importing_account: "正在匯入 Trade Republic 帳戶..." + processing_activity: "正在處理持股與活動..." + calculating_balances: "正在計算餘額..." + repair: + scheduled: "已排程修復 Trade Republic 資料。" + select_existing_account: + title: "連結 Trade Republic 帳戶" + link: "連結" + cancel: "取消" + initiate_login: + verification_required: "登入已開始。請透過 %{method} 確認,然後按下確認登入。" + complete_login: + approval_pending: "尚未確認。請在 Trade Republic 應用程式中核准登入後再試一次。" + success: "Trade Republic 已成功連線。" + login_expired: "登入要求已過期。請重新開始。" + link_existing_account: + not_found: "找不到帳戶或 Trade Republic 設定。" + success: "已成功連結至 Trade Republic 帳戶。" + complete_account_setup: + success: + one: "已成功建立 %{count} 個 Trade Republic 帳戶。" + other: "已成功建立 %{count} 個 Trade Republic 帳戶。" + none_created: "未建立任何帳戶。" + partial_failure: + one: "無法完整設定所選的一個 Trade Republic 帳戶。請檢查除錯記錄並重試。" + other: "無法完整設定所選的 %{count} 個 Trade Republic 帳戶。請檢查除錯記錄並重試。" + activities: + labels: + contribution: 入金 + withdrawal: 出金 + interest: 利息 + dividend: 股息 + card_payment: 刷卡付款 + cash_withdrawal: 提領現金 + card_fee: 卡片費用 + card_refund: 卡片退款 + tax_refund: 退稅 + buy: 買入 + sell: 賣出 diff --git a/config/routes.rb b/config/routes.rb index dac230640..099f49c59 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -198,6 +198,29 @@ Rails.application.routes.draw do end end + # Trade Republic routes (login steps are TR-specific: web login needs a + # two-phase initiate/confirm handshake with the Trade Republic app) + resources :trade_republic_items, only: [ :show, :create, :update, :destroy ] do + collection do + get :select_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + post :repair + get :setup_accounts + post :complete_account_setup + post :initiate_login + post :complete_login + post :poll_login + post :initiate_qr_login + post :poll_qr_login + post :cancel_qr_login + end + end + # CoinStats routes resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do collection do diff --git a/db/migrate/20260824200000_create_trade_republic_items_and_accounts.rb b/db/migrate/20260824200000_create_trade_republic_items_and_accounts.rb new file mode 100644 index 000000000..2fd248bed --- /dev/null +++ b/db/migrate/20260824200000_create_trade_republic_items_and_accounts.rb @@ -0,0 +1,44 @@ +class CreateTradeRepublicItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + create_table :trade_republic_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + t.string :status, default: "good", null: false + t.string :currency + t.string :phone_number + t.text :session_blob + t.text :pending_login_state + t.string :newest_event_id + t.boolean :scheduled_for_deletion, default: false, null: false + t.boolean :pending_account_setup, default: false, null: false + + t.timestamps + end + + add_index :trade_republic_items, :status + + create_table :trade_republic_accounts, id: :uuid do |t| + t.references :trade_republic_item, null: false, foreign_key: true, type: :uuid + t.string :name + t.string :trade_republic_account_id + t.string :account_type + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.decimal :cash_balance, precision: 19, scale: 4 + t.jsonb :raw_positions_payload, default: [], null: false + t.jsonb :raw_timeline_payload, default: [], null: false + t.datetime :last_positions_sync + t.boolean :holdings_snapshot_complete, default: false, null: false + t.string :kind, default: "portfolio", null: false + + t.timestamps + end + + add_index :trade_republic_accounts, [ :trade_republic_item_id, :trade_republic_account_id ], + unique: true, + where: "(trade_republic_account_id IS NOT NULL)", + name: "index_trade_republic_accounts_on_item_and_account_id" + + add_index :trade_republic_accounts, [ :trade_republic_item_id, :kind ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 307e0f13f..20971ebc7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2297,6 +2297,43 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_26_090000) do t.index ["message_id"], name: "index_tool_calls_on_message_id" end + create_table "trade_republic_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "account_type" + t.decimal "cash_balance", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.boolean "holdings_snapshot_complete", default: false, null: false + t.string "kind", default: "portfolio", null: false + t.datetime "last_positions_sync" + t.string "name" + t.jsonb "raw_positions_payload", default: [], null: false + t.jsonb "raw_timeline_payload", default: [], null: false + t.string "trade_republic_account_id" + t.uuid "trade_republic_item_id", null: false + t.datetime "updated_at", null: false + t.index ["trade_republic_item_id", "kind"], name: "idx_on_trade_republic_item_id_kind_3b60cc72fb", unique: true + t.index ["trade_republic_item_id", "trade_republic_account_id"], name: "index_trade_republic_accounts_on_item_and_account_id", unique: true, where: "(trade_republic_account_id IS NOT NULL)" + t.index ["trade_republic_item_id"], name: "index_trade_republic_accounts_on_trade_republic_item_id" + end + + create_table "trade_republic_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "currency" + t.uuid "family_id", null: false + t.string "name" + t.string "newest_event_id" + t.boolean "pending_account_setup", default: false, null: false + t.text "pending_login_state" + t.string "phone_number" + t.boolean "scheduled_for_deletion", default: false, null: false + t.text "session_blob" + t.string "status", default: "good", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_trade_republic_items_on_family_id" + t.index ["status"], name: "index_trade_republic_items_on_status" + end + create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.string "currency" @@ -2681,6 +2718,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_26_090000) do add_foreign_key "taggings", "tags" add_foreign_key "tags", "families" add_foreign_key "tool_calls", "messages" + add_foreign_key "trade_republic_accounts", "trade_republic_items" + add_foreign_key "trade_republic_items", "families" add_foreign_key "trades", "securities" add_foreign_key "trading212_accounts", "trading212_items" add_foreign_key "trading212_items", "families" diff --git a/test/controllers/settings/providers_controller_test.rb b/test/controllers/settings/providers_controller_test.rb index ac0efd577..e40f83427 100644 --- a/test/controllers/settings/providers_controller_test.rb +++ b/test/controllers/settings/providers_controller_test.rb @@ -510,6 +510,22 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest refute_includes response.body, I18n.t("settings.providers.drawer_trust_statement") end + test "GET show includes Trade Republic in bank sync providers" do + get settings_providers_url + + assert_response :success + assert_match(/Trade Republic/i, response.body) + assert_match(/Approve the login in your Trade Republic app/i, response.body) + end + + test "GET connect_form renders Trade Republic panel" do + get connect_form_settings_providers_path(provider_key: "trade_republic") + + assert_response :success + assert_match(/Trade Republic/i, response.body) + assert_match(I18n.t("settings.providers.trade_republic_panel.phone_number_label"), response.body) + end + test "GET connect_form for snaptrade shows OAuth setup instructions when instance is not configured" do Provider::Snaptrade.stubs(:oauth_configured?).returns(false) diff --git a/test/controllers/trade_republic_items_controller_test.rb b/test/controllers/trade_republic_items_controller_test.rb new file mode 100644 index 000000000..d7b9ea20f --- /dev/null +++ b/test/controllers/trade_republic_items_controller_test.rb @@ -0,0 +1,187 @@ +require "test_helper" + +class TradeRepublicItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + end + + test "create rejects a web login without a PIN before persisting the item" do + assert_no_difference "TradeRepublicItem.count" do + post trade_republic_items_url, params: { + trade_republic_item: { + phone_number: "+491701234567", + pin: "" + } + } + end + + assert_redirected_to settings_providers_path(anchor: "trade-republic") + assert_equal I18n.t("trade_republic_items.initiate_login.pin_required"), flash[:alert] + end + + test "update rejects a changed phone number without a PIN" do + item = trade_republic_items(:configured_item) + original_phone_number = item.phone_number + original_session_blob = item.session_blob + + patch trade_republic_item_url(item), params: { + trade_republic_item: { + phone_number: "+491709999999", + pin: "" + } + } + + assert_redirected_to settings_providers_path(anchor: "trade-republic") + assert_equal I18n.t("trade_republic_items.update.pin_required"), flash[:alert] + item.reload + assert_equal original_phone_number, item.phone_number + assert_equal original_session_blob, item.session_blob + end + + test "initiate login does not destroy a working session when the PIN is missing" do + item = trade_republic_items(:configured_item) + original_session_blob = item.session_blob + + post initiate_login_trade_republic_item_url(item) + + assert_redirected_to settings_providers_path(anchor: "trade-republic") + assert_equal I18n.t("trade_republic_items.initiate_login.pin_required"), flash[:alert] + item.reload + assert_equal original_session_blob, item.session_blob + assert_predicate item, :good? + end + + test "initiate login clears an expired pending state when the PIN is missing" do + item = trade_republic_items(:requires_update_item) + item.update!(pending_login_state: "expired-state") + + post initiate_login_trade_republic_item_url(item) + + assert_redirected_to settings_providers_path(anchor: "trade-republic") + assert_equal I18n.t("trade_republic_items.initiate_login.pin_required"), flash[:alert] + item.reload + assert_nil item.pending_login_state + assert_predicate item, :requires_update? + end + + test "complete account setup creates and links the selected account" do + item = trade_republic_items(:configured_item) + provider_account = trade_republic_accounts(:main_account) + + assert_difference "Account.count", 1 do + assert_difference "AccountProvider.count", 1 do + post complete_account_setup_trade_republic_item_url(item), params: { + account_ids: [ provider_account.id ] + } + end + end + + assert_redirected_to accounts_path + provider_account.reload + assert_not_nil provider_account.current_account + end + + test "complete account setup rolls back an account when linking fails" do + item = trade_republic_items(:configured_item) + provider_account = trade_republic_accounts(:main_account) + TradeRepublicAccount.any_instance.stubs(:ensure_account_provider!).returns(nil) + + assert_no_difference "Account.count" do + assert_no_difference "AccountProvider.count" do + post complete_account_setup_trade_republic_item_url(item), params: { + account_ids: [ provider_account.id ] + } + end + end + + assert_redirected_to setup_accounts_trade_republic_item_path(item) + assert_equal I18n.t("trade_republic_items.complete_account_setup.partial_failure", count: 1), flash[:alert] + end + + test "link_existing_account rejects an account already connected to another provider" do + item = trade_republic_items(:no_session_item) + trade_republic_account = trade_republic_accounts(:pending_setup_account) + account = accounts(:connected) + account.reload + assert_not_nil account.plaid_account_id + + assert_no_difference "AccountProvider.count" do + post link_existing_account_trade_republic_items_url, params: { + account_id: account.id, + trade_republic_account_id: trade_republic_account.id + } + end + + assert_redirected_to account_path(account) + assert_equal I18n.t("trade_republic_items.link_existing_account.only_manual_investment"), flash[:alert] + trade_republic_account.reload + assert_nil trade_republic_account.current_account + assert_equal item, trade_republic_account.trade_republic_item + end + + test "successful QR polling can complete without a phone number" do + item = families(:dylan_family).trade_republic_items.create!( + name: "Trade Republic QR Connection", + currency: "EUR", + status: :requires_update + ) + item.update!(pending_login_state: "qr-pending") + provider = mock + provider.expects(:poll_qr_login).with(pending_login_b64: "qr-pending").returns( + Provider::TradeRepublicClient::Result.new( + data: { "status" => "confirmed", "session_txt" => "qr-session" } + ) + ) + TradeRepublicItem.any_instance.stubs(:trade_republic_provider).returns(provider) + TradeRepublicItem.any_instance.stubs(:syncing?).returns(true) + + post poll_qr_login_trade_republic_item_url(item), headers: { "ACCEPT" => "application/json" } + + assert_response :success + item.reload + assert_predicate item, :good? + assert_predicate item, :session_configured? + assert_nil item.pending_login_state + assert_nil item.phone_number + end + + test "QR polling exposes transient provider failures as retryable" do + item = families(:dylan_family).trade_republic_items.create!( + name: "Trade Republic QR Connection", + currency: "EUR", + status: :requires_update + ) + item.update!(pending_login_state: "qr-pending") + provider = mock + provider.expects(:poll_qr_login).with(pending_login_b64: "qr-pending").raises( + Provider::TradeRepublicClient::Timeout, + "Trade Republic WebSocket timed out" + ) + TradeRepublicItem.any_instance.stubs(:trade_republic_provider).returns(provider) + + post poll_qr_login_trade_republic_item_url(item), headers: { "ACCEPT" => "application/json" } + + assert_response :service_unavailable + assert_equal true, JSON.parse(response.body).fetch("retryable") + assert_equal "qr-pending", item.reload.pending_login_state + end + + test "successful web login renders a dialog button that closes the modal" do + item = trade_republic_items(:requires_update_item) + item.update!(pending_login_state: "pending-login") + provider = mock + provider.expects(:complete_login).with(pending_login_b64: "pending-login").returns( + Provider::TradeRepublicClient::Result.new( + data: { "status" => "confirmed", "session_txt" => "session" } + ) + ) + TradeRepublicItem.any_instance.stubs(:trade_republic_provider).returns(provider) + TradeRepublicItem.any_instance.stubs(:syncing?).returns(true) + + post poll_login_trade_republic_item_url(item), headers: { "ACCEPT" => "text/vnd.turbo-stream.html" } + + assert_response :success + assert_includes response.body, 'data-action="DS--dialog#close"' + assert_includes response.body, I18n.t("settings.providers.trade_republic_panel.connection_success.close") + end +end diff --git a/test/controllers/trade_republic_panel_render_test.rb b/test/controllers/trade_republic_panel_render_test.rb new file mode 100644 index 000000000..7475b83bc --- /dev/null +++ b/test/controllers/trade_republic_panel_render_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class TradeRepublicPanelRenderTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + end + + test "expired login state renders restart login via DS::Button" do + TradeRepublicItem.any_instance.stubs(:login_stage).returns("expired") + TradeRepublicItem.any_instance.stubs(:pending_login_state).returns("some-state") + + get connect_form_settings_providers_path(provider_key: "trade_republic") + assert_response :success + + assert_includes response.body, I18n.t("settings.providers.trade_republic_panel.restart_login") + assert_includes response.body, '
"cash_evt" } ] + ) + adapter = Account::ProviderImportAdapter.new(@account) + adapter.import_transaction( + external_id: "trade_republic_event_cash_evt", + amount: BigDecimal("-25.00"), + currency: "EUR", + date: Date.current, + name: "Cash payment", + source: "trade_republic" + ) + adapter.import_transaction( + external_id: "trade_republic_event_unknown_evt", + amount: BigDecimal("-10.00"), + currency: "EUR", + date: Date.current, + name: "Unseen payment", + source: "trade_republic" + ) + + TradeRepublicAccount::ActivitiesProcessor.new(@tr_account.reload).process + + assert Entry.exists?(external_id: "trade_republic_event_cash_evt") + assert Entry.exists?(external_id: "trade_republic_event_unknown_evt") + end + + private + + def import_event(event) + @tr_account.update!(raw_timeline_payload: [ event ]) + TradeRepublicAccount::ActivitiesProcessor.new(@tr_account.reload).process + end + + def order_execution_detail(event_id: "evt_buy", quantity:, isin:, amount:) + { + id: event_id, + timestamp: "2026-07-15T09:30:00Z", + category: "orderExecution", + detail: { + isin: isin, + name: "Apple Inc.", + quantity: quantity, + amount: amount, + currency: "EUR" + } + } + end + + def deposit_event + { + id: "evt_dep", + timestamp: "2026-08-01T10:00:00Z", + category: "PAYMENT_RECEIVED", + detail: { amount: "500.00", currency: "EUR" } + } + end + + def find_trade(external_id) + Entry.find_by(external_id: external_id) + end +end diff --git a/test/models/trade_republic_account_holdings_processor_test.rb b/test/models/trade_republic_account_holdings_processor_test.rb new file mode 100644 index 000000000..653f036a6 --- /dev/null +++ b/test/models/trade_republic_account_holdings_processor_test.rb @@ -0,0 +1,136 @@ +require "test_helper" + +class TradeRepublicAccountHoldingsProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = trade_republic_items(:configured_item) + @item.trade_republic_accounts.destroy_all + + @tr_account = @item.trade_republic_accounts.create!( + name: "Holdings Test", + trade_republic_account_id: "DEHOLD1", + currency: "EUR" + ) + @account = @family.accounts.create!( + name: "Trade Republic Holdings Test", + balance: 0, + cash_balance: 0, + currency: "EUR", + accountable: Investment.new + ) + @tr_account.ensure_account_provider!(@account) + @tr_account.reload + end + + test "imports holding with fractional quantity and exact math" do + import_position(isin: "US0378331005", quantity: "13.439945", price: "183.94", average_cost: "150.10") + + holding = @account.holdings.find_by(external_id: "trade_republic_position_DEHOLD1_US0378331005_#{Date.current}") + + assert_not_nil holding + assert_equal BigDecimal("13.439945"), holding.qty + assert_equal BigDecimal("13.439945").to_s, holding.qty.to_s + assert_equal BigDecimal("183.94"), holding.price + + # qty keeps 8 fractional digits; the amount column stores scale-4 + # (Sure-wide convention), so compare against the same rounding. + expected_amount = (BigDecimal("13.439945") * BigDecimal("183.94")).round(4) + assert_equal expected_amount, holding.amount + end + + test "sync twice keeps a single holding per position" do + position = position_payload(isin: "US0378331005", quantity: "13.439945", price: "183.94") + + @tr_account.update!(raw_positions_payload: [ position ]) + TradeRepublicAccount::HoldingsProcessor.new(@tr_account).process + + assert_no_difference "@account.holdings.count" do + TradeRepublicAccount::HoldingsProcessor.new(@tr_account.reload).process + end + end + + test "position without valid price is skipped rather than guessed" do + assert_no_difference "@account.holdings.count" do + import_position(isin: "US0378331005", quantity: "13.439945", price: nil) + end + end + + test "empty portfolio creates no holdings and preserves prior financial state" do + import_position(isin: "US5933661043", quantity: "2", price: "100") + holdings_before = @account.holdings.count + + # Successful but empty snapshot: nothing new to import, nothing destroyed. + @tr_account.update!(raw_positions_payload: []) + TradeRepublicAccount::HoldingsProcessor.new(@tr_account.reload).process + + assert_equal holdings_before, @account.holdings.count + end + + test "explicit successful empty portfolio removes prior Trade Republic holdings" do + import_position(isin: "US5933661043", quantity: "2", price: "100") + @tr_account.update!(holdings_snapshot_complete: true, raw_positions_payload: []) + + TradeRepublicAccount::HoldingsProcessor.new(@tr_account.reload).process + + assert_nil @account.holdings.find_by(external_id: "trade_republic_position_DEHOLD1_US5933661043_#{Date.current}") + end + + test "complete snapshot removes stale Trade Republic holdings" do + import_position(isin: "US5933661043", quantity: "2", price: "100") + @tr_account.update!(holdings_snapshot_complete: true) + + @tr_account.update!(raw_positions_payload: [ position_payload(isin: "US0378331005", quantity: "1", price: "200") ]) + TradeRepublicAccount::HoldingsProcessor.new(@tr_account.reload).process + + assert_nil @account.holdings.find_by(external_id: "trade_republic_position_DEHOLD1_US5933661043_#{Date.current}") + assert_not_nil @account.holdings.find_by(external_id: "trade_republic_position_DEHOLD1_US0378331005_#{Date.current}") + end + + test "complete snapshot preserves holdings from previous dates" do + import_position(isin: "US5933661043", quantity: "2", price: "100") + historical_holding = @account.holdings.find_by!(external_id: "trade_republic_position_DEHOLD1_US5933661043_#{Date.current}") + historical_holding.update!(external_id: "trade_republic_position_DEHOLD1_US5933661043_#{Date.yesterday}") + + @tr_account.update!(holdings_snapshot_complete: true, raw_positions_payload: []) + TradeRepublicAccount::HoldingsProcessor.new(@tr_account.reload).process + + assert @account.holdings.exists?(external_id: "trade_republic_position_DEHOLD1_US5933661043_#{Date.yesterday}") + end + + test "incomplete snapshot preserves stale Trade Republic holdings" do + import_position(isin: "US5933661043", quantity: "2", price: "100") + @tr_account.update!(holdings_snapshot_complete: false) + + @tr_account.update!(raw_positions_payload: [ position_payload(isin: "US0378331005", quantity: "1", price: "200") ]) + TradeRepublicAccount::HoldingsProcessor.new(@tr_account.reload).process + + assert_not_nil @account.holdings.find_by(external_id: "trade_republic_position_DEHOLD1_US5933661043_#{Date.current}") + end + + test "zero and negative quantities are not imported" do + assert_no_difference "@account.holdings.count" do + @tr_account.update!(raw_positions_payload: [ + position_payload(isin: "US0378331005", quantity: "0", price: "200"), + position_payload(isin: "US5933661043", quantity: "-1", price: "200") + ]) + TradeRepublicAccount::HoldingsProcessor.new(@tr_account.reload).process + end + end + + private + + def import_position(isin:, quantity:, price:, average_cost: nil) + @tr_account.update!(raw_positions_payload: [ position_payload(isin:, quantity:, price:, average_cost:) ]) + TradeRepublicAccount::HoldingsProcessor.new(@tr_account.reload).process + end + + def position_payload(isin:, quantity:, price:, average_cost: nil) + { + "isin" => isin, + "name" => "Test Security", + "quantity" => quantity, + "price" => price, + "average_cost" => average_cost + }.compact + end +end diff --git a/test/models/trade_republic_client_test.rb b/test/models/trade_republic_client_test.rb new file mode 100644 index 000000000..0e1fc50cd --- /dev/null +++ b/test/models/trade_republic_client_test.rb @@ -0,0 +1,405 @@ +require "test_helper" + +class TradeRepublicClientTest < ActiveSupport::TestCase + setup do + @client = Provider::TradeRepublicClient.new(phone_number: "+491701234567", pin: "1234") + end + + test "authentication requires a transient PIN" do + client = Provider::TradeRepublicClient.new(phone_number: "+491701234567") + + assert_raises(Provider::TradeRepublicClient::ConfigurationError) do + client.initiate_login + end + end + + test "sync requires an encrypted session blob" do + assert_raises(Provider::TradeRepublicClient::ConfigurationError) do + @client.sync(session_txt: nil) + end + end + + test "authenticated login rejects an account without a securities account number" do + response_class = Struct.new(:code, :body) do + def is_a?(klass) + return true if klass == Net::HTTPSuccess + + super + end + end + session = mock + session.stubs(:login_headers).returns({}) + session.expects(:get).with("/api/v2/auth/account", headers: {}).returns( + response_class.new("200", { "currency" => "EUR" }.to_json) + ) + + error = assert_raises(Provider::TradeRepublicClient::MalformedResponse) do + @client.send(:authenticated_session_result, session) + end + + assert_match(/securities account number/i, error.message) + end + + test "reconstructs Trade Republic delta websocket payloads" do + previous = '{"items":[1,2,3]}' + delta = "=15\t+%2C4%5D%7D" + + assert_equal '{"items":[1,2,3,4]}', @client.send(:apply_delta, previous, delta) + end + + test "rejects a delta without a base response" do + assert_raises(Provider::TradeRepublicClient::MalformedResponse) do + @client.send(:apply_delta, nil, "=2") + end + end + + test "extracts provider error codes from both supported response shapes" do + response = Struct.new(:body) + top_level = response.new('{"errorCode":"MISSING_REQUIRED_HEADER"}') + nested = response.new('{"errors":[{"errorCode":"AUTHENTICATION_ERROR"}]}') + + assert_equal "MISSING_REQUIRED_HEADER", @client.send(:response_error_code, top_level) + assert_equal "AUTHENTICATION_ERROR", @client.send(:response_error_code, nested) + end + + test "surfaces WAF failures as an actionable provider error" do + response = Struct.new(:body, :code).new('{"errorCode":"MISSING_REQUIRED_HEADER"}', "400") + + assert_raises(Provider::TradeRepublicClient::WafRequired) do + @client.send(:raise_http_error, response) + end + end + + test "normalizes current timeline event types to import categories" do + assert_equal "orderExecution", Provider::TradeRepublicClient::EVENT_TYPE_CATEGORIES["TRADING_TRADE_EXECUTED"] + assert_equal "orderExecution", Provider::TradeRepublicClient::EVENT_TYPE_CATEGORIES["TRADE_INVOICE"] + assert_equal "DIVIDEND", Provider::TradeRepublicClient::EVENT_TYPE_CATEGORIES["DIVIDEND"] + assert_equal "orderExecution", Provider::TradeRepublicClient::EVENT_TYPE_CATEGORIES["PRIVATE_MARKET_FUND_TRADE_EXECUTED"] + assert_equal "POC_CREATED", Provider::TradeRepublicClient::EVENT_TYPE_CATEGORIES["CARD_ATM_WITHDRAWAL"] + assert_equal "POC_CREATED", Provider::TradeRepublicClient::EVENT_TYPE_CATEGORIES["CARD_TRANSACTION"] + assert_equal "POC_CREATED", Provider::TradeRepublicClient::EVENT_TYPE_CATEGORIES["CARD_CASH_BACK"] + end + + test "merges transaction and activity timelines without duplicate events" do + responses = { + "timelineTransactions" => [ [ { "id" => "cash-1", "timestamp" => "2026-08-02" } ], "cash-1", [], true ], + "timelineActivityLog" => [ [ { "id" => "cash-1", "timestamp" => "2026-08-02" }, { "id" => "trade-1", "timestamp" => "2026-08-03" } ], "trade-1", [], true ] + } + @client.define_singleton_method(:collect_timeline_topic) do |_websocket, topic:, **_| + responses.fetch(topic) + end + + events, newest_id, warnings = @client.send(:collect_all_timeline, Object.new, known_newest_event_id: nil, max_pages: 2) + + assert_equal %w[cash-1 trade-1], events.map { |event| event["id"] } + assert_equal "trade-1", newest_id + assert_empty warnings + end + + test "does not advance the cursor when timeline details are incomplete" do + @client.define_singleton_method(:collect_timeline_topic) do |_websocket, topic:, **_| + if topic == "timelineTransactions" + [ [ { "id" => "event-1", "timestamp" => "2026-08-02" } ], nil, [], true ] + else + [ [], nil, [], true ] + end + end + + events, newest_id, warnings, complete = @client.send(:collect_all_timeline, Object.new, known_newest_event_id: nil, max_pages: 2) + + assert_equal [ "event-1" ], events.map { |event| event["id"] } + assert_nil newest_id + assert_empty warnings + refute complete + end + + test "recognizes QR login pending state" do + pending = { "challenge_id" => "challenge-1", "session_blob" => "session=1", "expires_at" => 1.minute.from_now.iso8601 } + encoded = Base64.strict_encode64(JSON.generate(pending)) + + assert @client.qr_login?(pending_login_b64: encoded) + assert_equal "qr_pending", @client.login_stage(pending_login_b64: encoded) + end + + test "rotates the QR payload and token expiry while polling" do + response_class = Struct.new(:code, :body) do + def is_a?(klass) + return true if klass == Net::HTTPSuccess + + super + end + end + session = mock + session.stubs(:login_headers).returns({}) + session.expects(:get).with(regexp_matches(%r{/qr-challenges/}), headers: {}).returns( + response_class.new("200", { + "status" => "PENDING", + "qrCodePayload" => "https://trade-republic.example/rotated-token", + "qrCodeTokenExpiresAt" => 10.seconds.from_now.iso8601 + }.to_json) + ) + @client.define_singleton_method(:new_session) { |session_blob:| session } + + pending = { + "challenge_id" => "challenge-1", + "session_blob" => "session=1", + "expires_at" => 1.minute.from_now.iso8601, + "qr_code_payload" => "https://trade-republic.example/old-token", + "qr_code_token_expires_at" => 1.second.ago.iso8601 + } + + result = @client.poll_qr_login(pending_login_b64: Base64.strict_encode64(JSON.generate(pending))) + next_pending = JSON.parse(Base64.strict_decode64(result.data.fetch("pending_login_b64"))) + + assert_equal "https://trade-republic.example/rotated-token", result.data["qr_code_payload"] + assert_equal next_pending["qr_code_token_expires_at"], result.data["qr_code_token_expires_at"] + assert_equal "https://trade-republic.example/rotated-token", next_pending["qr_code_payload"] + end + + test "recognizes Trade Republic approval states from state or status" do + %w[APPROVED CONFIRMED COMPLETED SUCCESS OK DONE].each do |state| + assert @client.send(:login_process_completed?, { "state" => state }) + assert @client.send(:login_process_completed?, { "status" => state }) + end + + assert @client.send(:login_process_completed?, { "state" => "pending", "status" => "approved" }) + assert @client.send(:login_process_completed?, { "statusCode" => "completed" }) + refute @client.send(:login_process_completed?, { "state" => "PENDING" }) + end + + test "extracts cash from nested money payloads" do + payload = { "cash" => { "available" => { "value" => "123.45", "currency" => "EUR" } } } + + assert_equal "123.45", @client.send(:money_amount, payload).to_s + assert_equal "EUR", @client.send(:money_currency, payload) + end + + test "keeps positions when Trade Republic has no supported ticker price" do + @client.define_singleton_method(:subscribe) do |_websocket, payload| + raise Provider::TradeRepublicClient::ProviderUnavailable if payload[:id].end_with?(".LSX") + + { "last" => { "price" => "250.00" } } + end + + positions, warnings = @client.send(:normalize_positions, Object.new, { + "categories" => [ + { "categoryType" => "cryptos", "positions" => [ + { "instrumentId" => "XF000BTC0017", "name" => "Bitcoin", "netSize" => "0.1" } + ] } + ] + }) + + assert_equal "crypto_wallet", positions.first["category"] + assert_equal "250.00", positions.first["price"] + assert_empty warnings + end + + test "preserves an unpriced position for category visibility" do + @client.define_singleton_method(:subscribe) do |_websocket, **_payload| + raise Provider::TradeRepublicClient::ProviderUnavailable + end + + positions, warnings = @client.send(:normalize_positions, Object.new, { + "categories" => [ + { "categoryType" => "cryptos", "positions" => [ + { "instrumentId" => "XF000ETH0019", "name" => "Ethereum", "netSize" => "1.5" } + ] } + ] + }) + + assert_equal({ + "isin" => "XF000ETH0019", + "name" => "Ethereum", + "category" => "crypto_wallet", + "quantity" => "1.5" + }, positions.first) + assert_equal [ "price unavailable for XF000ETH0019; position kept without valuation" ], warnings + end + + test "preserves a position when a ticker subscription times out" do + @client.define_singleton_method(:subscribe) do |_websocket, **_payload| + raise Provider::TradeRepublicClient::Timeout, "ticker did not answer" + end + + positions, warnings = @client.send(:normalize_positions, Object.new, { + "categories" => [ + { "categoryType" => "stocksAndETFs", "positions" => [ + { "instrumentId" => "LU3176111881", "name" => "ETF", "netSize" => "2.25" } + ] } + ] + }) + + assert_equal "2.25", positions.first["quantity"] + assert_nil positions.first["price"] + assert_equal [ "price unavailable for LU3176111881; position kept without valuation" ], warnings + end + + test "marks a snapshot partial when malformed positions are skipped" do + @client.expects(:position_price).never + + positions, warnings = @client.send(:normalize_positions, Object.new, { + "categories" => [ + { "categoryType" => "stocksAndETFs", "positions" => [ + { "name" => "Missing identity", "netSize" => "2" }, + { "instrumentId" => "US0378331005", "name" => "Missing quantity" } + ] } + ] + }) + + assert_empty positions + assert_equal [ + "malformed portfolio position skipped: missing instrument ID", + "malformed portfolio position skipped: missing quantity" + ], warnings + end + + test "resolves event type and preserves the signed timeline amount" do + @client.define_singleton_method(:subscribe) do |_websocket, **_payload| + { + "sections" => [ + { "title" => "Overview", "data" => [ + { "title" => "Shares", "detail" => { "text" => "1.5" } }, + { "title" => "Total", "detail" => { "text" => "€100.00" } } + ] }, + { "title" => "Asset", "data" => [ + { "title" => "Apple", "detail" => { "text" => "Apple" } } + ] }, + { "data" => [ { "detail" => { "action" => { "payload" => { "instrumentId" => "US0378331005" } } } } ] } + ] + } + end + + events, = @client.send(:resolve_details, Object.new, [ + { + "id" => "evt-1", + "timestamp" => "2026-08-01T10:00:00Z", + "eventType" => "TRADING_TRADE_EXECUTED", + "amount" => { "value" => -100.0, "currency" => "EUR" } + } + ], nil, []) + + assert_equal "orderExecution", events.first["category"] + assert_equal(-100.0, events.first.dig("detail", "signed_amount")) + end + + test "rejects an expired pending login state" do + pending = { + "process_id" => "process-1", + "session_blob" => "session=1", + "expires_at" => 1.minute.ago.iso8601 + } + encoded = Base64.strict_encode64(JSON.generate(pending)) + + assert_raises(Provider::TradeRepublicClient::LoginExpired) do + @client.send(:decode_pending, encoded) + end + end + + test "does not collapse distinct timeline events that have no id" do + responses = { + "timelineTransactions" => [ + [ + { "timestamp" => "2026-08-02T10:00:00Z", "eventType" => "CARD_TRANSACTION" }, + { "timestamp" => "2026-08-02T11:00:00Z", "eventType" => "CARD_TRANSACTION" } + ], + nil, + [] + ], + "timelineActivityLog" => [ [], nil, [] ] + } + @client.define_singleton_method(:collect_timeline_topic) do |_websocket, topic:, **_| + responses.fetch(topic) + end + + events, = @client.send(:collect_all_timeline, Object.new, known_newest_event_id: nil, max_pages: 1) + + assert_equal 2, events.size + end + + test "keeps the page containing the cursor as an overlap window" do + @client.define_singleton_method(:subscribe) do |_websocket, payload| + if payload[:type] == "timelineTransactions" + { "items" => [ { "id" => "new", "timestamp" => "2026-08-03" }, { "id" => "old", "timestamp" => "2026-08-02" } ], "cursors" => { "after" => "next-page" } } + else + { "items" => [], "cursors" => {} } + end + end + + events, = @client.send(:collect_all_timeline, Object.new, known_newest_event_id: "old", max_pages: 2) + + assert_equal %w[new old], events.map { |event| event["id"] } + end + + test "retries a network timeout with bounded backoff" do + attempts = 0 + @client.stubs(:sleep_for) + @client.define_singleton_method(:sync_once) do |**_| + attempts += 1 + raise Provider::TradeRepublicClient::Timeout, "timeout" if attempts == 1 + + :ok + end + + assert_equal :ok, @client.sync(session_txt: "session") + assert_equal 2, attempts + end + + test "uses Retry-After for a bounded rate-limit retry" do + attempts = 0 + sleeps = [] + @client.define_singleton_method(:sleep_for) { |seconds| sleeps << seconds } + @client.define_singleton_method(:sync_once) do |**_| + attempts += 1 + raise Provider::TradeRepublicClient::RateLimited.new("rate limited", retry_after: 1.25) if attempts == 1 + + :ok + end + + assert_equal :ok, @client.sync(session_txt: "session") + assert_equal [ 1.25 ], sleeps + end + + test "does not retry expired sessions or malformed payloads" do + [ Provider::TradeRepublicClient::AuthenticationRequired, Provider::TradeRepublicClient::MalformedResponse ].each do |error_class| + attempts = 0 + @client.define_singleton_method(:sleep_for) { |_seconds| flunk "unexpected retry" } + @client.define_singleton_method(:sync_once) do |**_| + attempts += 1 + raise error_class, "fatal" + end + + assert_raises(error_class) { @client.sync(session_txt: "session") } + assert_equal 1, attempts + end + end + + test "rejects malformed QR login state" do + assert_raises(Provider::TradeRepublicClient::InvalidChallenge) do + @client.send(:decode_qr_pending, "not-base64") + end + end + + test "rejects expired QR login state" do + pending = { + "challenge_id" => "challenge-1", + "session_blob" => "session=1", + "expires_at" => 1.minute.ago.iso8601 + } + encoded = Base64.strict_encode64(JSON.generate(pending)) + + assert_raises(Provider::TradeRepublicClient::LoginExpired) do + @client.send(:decode_qr_pending, encoded) + end + end + + test "classifies an already processed QR token as expired" do + response = Struct.new(:code, :body).new( + "409", + { "errors" => [ { "errorCode" => "ALREADY_PROCESSED" } ] }.to_json + ) + + assert_raises(Provider::TradeRepublicClient::LoginExpired) do + @client.send(:raise_login_error, response) + end + end +end diff --git a/test/models/trade_republic_item_importer_test.rb b/test/models/trade_republic_item_importer_test.rb new file mode 100644 index 000000000..0d485a3de --- /dev/null +++ b/test/models/trade_republic_item_importer_test.rb @@ -0,0 +1,334 @@ +require "test_helper" + +class TradeRepublicItemImporterTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = trade_republic_items(:configured_item) + @item.trade_republic_accounts.destroy_all + end + + test "import creates trade_republic_account with exact decimal balances" do + provider = mock("trade_republic_provider") + provider.expects(:sync).returns(client_result( + "status" => "ok", + "session_txt" => "# refreshed cookies", + "account" => { "brokerage_account_id" => "DE9999", "currency" => "EUR" }, + "cash" => { "amount" => "250.55", "currency" => "EUR" }, + "positions" => [ + { "isin" => "US0378331005", "name" => "Apple Inc.", "quantity" => "13.439945", "price" => "183.94", "average_cost" => "150.10" } + ], + "events" => [], + "newest_event_id" => "evt_2", + "warnings" => [] + )) + + result = TradeRepublicItem::Importer.new(@item, provider: provider).import + + assert_equal({ success: true }, result) + + account = @item.trade_republic_accounts.find_by(trade_republic_account_id: "DE9999") + assert_not_nil account + + expected_portfolio = BigDecimal("13.439945") * BigDecimal("183.94") + assert_equal expected_portfolio.round(4), account.current_balance + assert_equal BigDecimal("0"), account.cash_balance + cash_account = @item.trade_republic_accounts.find_by(kind: "cash") + assert_equal BigDecimal("250.55"), cash_account.current_balance + assert_equal BigDecimal("250.55"), cash_account.cash_balance + assert_equal 1, account.raw_positions_payload.size + end + + test "repeated sync updates the same account row and stays idempotent" do + provider_payload = lambda { + client_result( + "status" => "ok", + "session_txt" => "# refreshed cookies", + "account" => { "brokerage_account_id" => "DE1111", "currency" => "EUR" }, + "cash" => { "amount" => "100.00", "currency" => "EUR" }, + "positions" => [], + "events" => [ { "id" => "evt_9", "timestamp" => "2026-08-01T12:00:00.000Z", "category" => "orderExecution" } ], + "newest_event_id" => "evt_9", + "warnings" => [] + ) + } + + provider = mock("trade_republic_provider") + provider.expects(:sync).twice.returns(provider_payload.call) + + TradeRepublicItem::Importer.new(@item, provider: provider).import + state_after_first_sync = @item.trade_republic_accounts.order(:kind).map do |account| + [ account.kind, account.trade_republic_account_id, account.current_balance.to_s, account.cash_balance.to_s, account.raw_positions_payload, account.raw_timeline_payload ] + end + TradeRepublicItem::Importer.new(@item, provider: provider).import + state_after_second_sync = @item.reload.trade_republic_accounts.order(:kind).map do |account| + [ account.kind, account.trade_republic_account_id, account.current_balance.to_s, account.cash_balance.to_s, account.raw_positions_payload, account.raw_timeline_payload ] + end + + assert_equal 1, @item.trade_republic_accounts.where(kind: "portfolio").count + assert_equal 1, @item.trade_republic_accounts.where(kind: "cash").count + assert_equal "evt_9", @item.reload.newest_event_id + assert_equal state_after_first_sync, state_after_second_sync + end + + test "preserves and recovers timeline events when the provider returns an empty delta" do + @item.trade_republic_accounts.destroy_all + account = @item.trade_republic_accounts.create!( + name: "Existing", + trade_republic_account_id: "DE4444", + currency: "EUR", + raw_timeline_payload: [ { "id" => "evt_old", "category" => "PAYMENT_RECEIVED" } ] + ) + @item.update!(newest_event_id: "evt_old") + + provider = mock("trade_republic_provider") + provider.expects(:sync).with { |args| args[:known_newest_event_id] == "evt_old" }.returns(client_result( + "status" => "ok", + "session_txt" => "# refreshed cookies", + "account" => { "brokerage_account_id" => "DE4444", "currency" => "EUR" }, + "cash" => { "amount" => "1", "currency" => "EUR" }, + "positions" => [], + "events" => [], + "newest_event_id" => "evt_old", + "warnings" => [] + )) + + TradeRepublicItem::Importer.new(@item, provider: provider).import + assert_equal [ "evt_old" ], account.reload.raw_timeline_payload.map { |event| event["id"] } + + @item.update!(newest_event_id: "evt_missing") + provider.expects(:sync).with { |args| args[:known_newest_event_id] == "evt_missing" }.returns(client_result( + "status" => "ok", + "session_txt" => "# refreshed cookies", + "account" => { "brokerage_account_id" => "DE4444", "currency" => "EUR" }, + "cash" => { "amount" => "1", "currency" => "EUR" }, + "positions" => [], + "events" => [ { "id" => "evt_recovered", "category" => "PAYMENT_RECEIVED" } ], + "newest_event_id" => "evt_recovered", + "warnings" => [] + )) + + TradeRepublicItem::Importer.new(@item, provider: provider).import + assert_equal %w[evt_old evt_recovered], account.reload.raw_timeline_payload.map { |event| event["id"] } + end + + test "session expiry marks item requires_update and preserves stored payloads" do + @item.trade_republic_accounts.create!( + name: "Existing", + trade_republic_account_id: "DE2222", + currency: "EUR", + current_balance: BigDecimal("42.00"), + cash_balance: BigDecimal("7.00"), + raw_positions_payload: [ { "isin" => "XX", "quantity" => "1", "price" => "1" } ] + ) + + provider = mock("trade_republic_provider") + provider.expects(:sync).returns(client_result( + "status" => "session_expired" + )) + + error = assert_raises(Provider::TradeRepublicClient::AuthenticationRequired) do + TradeRepublicItem::Importer.new(@item, provider: provider).import + end + + assert_match(/expired/i, error.message) + assert @item.reload.requires_update? + + account = @item.trade_republic_accounts.find_by(trade_republic_account_id: "DE2222") + assert_not_nil account.raw_positions_payload.first + assert_equal BigDecimal("42.00"), account.current_balance + end + + test "provider failure propagates without touching existing payloads" do + @item.trade_republic_accounts.create!( + name: "Existing", + trade_republic_account_id: "DE3333", + currency: "EUR", + current_balance: BigDecimal("99.00"), + cash_balance: BigDecimal("5.00"), + raw_positions_payload: [ { "isin" => "KEEP", "quantity" => "2", "price" => "3" } ] + ) + + provider = mock("trade_republic_provider") + provider.expects(:sync).raises(Provider::TradeRepublicClient::ProviderUnavailable, "network down") + + assert_raises(Provider::TradeRepublicClient::ProviderUnavailable) do + TradeRepublicItem::Importer.new(@item, provider: provider).import + end + + account = @item.trade_republic_accounts.find_by(trade_republic_account_id: "DE3333") + assert_equal [ { "isin" => "KEEP", "quantity" => "2", "price" => "3" } ], account.reload.raw_positions_payload + assert_equal BigDecimal("99.00"), account.current_balance + end + + test "ignores malformed timeline elements without breaking quality summaries" do + provider = mock("trade_republic_provider") + provider.expects(:sync).returns(client_result( + "status" => "ok", + "session_txt" => "# refreshed cookies", + "account" => { "brokerage_account_id" => "DE-MALFORMED", "currency" => "EUR" }, + "cash" => { "amount" => "1", "currency" => "EUR" }, + "positions" => [], + "events" => [ "unexpected-event", { "id" => "known", "category" => "PAYMENT_RECEIVED" } ], + "warnings" => [] + )) + + TradeRepublicItem::Importer.new(@item, provider: provider).import + + assert_equal 2, @item.reload.data_quality_summary[:events] + assert_equal 0, @item.data_quality_summary[:unknown_events] + end + + test "unpriced positions preserve the last known portfolio balance" do + portfolio = @item.trade_republic_accounts.create!( + kind: "portfolio", + name: "Existing portfolio", + currency: "EUR", + trade_republic_account_id: "DE5555", + current_balance: BigDecimal("1234.56"), + raw_positions_payload: [ { "isin" => "KEEP", "quantity" => "2", "price" => "617.28" } ] + ) + + provider = mock("trade_republic_provider") + provider.expects(:sync).returns(client_result( + "status" => "ok", + "session_txt" => "# refreshed cookies", + "account" => { "brokerage_account_id" => "DE5555", "currency" => "EUR" }, + "cash" => { "amount" => "0", "currency" => "EUR" }, + "positions" => [ { "isin" => "KEEP", "quantity" => "2" } ], + "events" => [], + "warnings" => [ "price unavailable for KEEP" ] + )) + + TradeRepublicItem::Importer.new(@item, provider: provider).import + + assert_equal BigDecimal("1234.56"), portfolio.reload.current_balance + assert_equal false, portfolio.holdings_snapshot_complete? + assert_equal "617.28", portfolio.raw_positions_payload.first["price"] + end + + test "cash success with timeline failure updates cash but preserves timeline and cursor" do + portfolio = @item.trade_republic_accounts.create!( + kind: "portfolio", + trade_republic_account_id: "DE-DOMAIN", + currency: "EUR", + raw_timeline_payload: [ { "id" => "old-event" } ] + ) + cash = @item.trade_republic_accounts.create!( + kind: "cash", + trade_republic_account_id: "cash:DE-DOMAIN", + currency: "EUR", + current_balance: BigDecimal("10.00"), + cash_balance: BigDecimal("10.00"), + raw_timeline_payload: [ { "id" => "old-event" } ] + ) + @item.update!(newest_event_id: "old-event") + + provider = mock("trade_republic_provider") + provider.expects(:sync).returns(client_result( + "status" => "partial", + "domain_statuses" => { "account_metadata" => "success", "cash" => "success", "portfolio" => "success", "timeline" => "failed", "instrument_metadata" => "success" }, + "account" => { "brokerage_account_id" => "DE-DOMAIN", "currency" => "EUR" }, + "cash" => { "amount" => "99.99", "currency" => "EUR" }, + "positions" => [], + "events" => [ { "id" => "new-event" } ], + "newest_event_id" => "new-event", + "warnings" => [] + )) + + TradeRepublicItem::Importer.new(@item, provider: provider).import + + assert_equal BigDecimal("99.99"), cash.reload.current_balance + assert_equal [ "old-event" ], portfolio.reload.raw_timeline_payload.map { |event| event["id"] } + assert_equal "old-event", @item.reload.newest_event_id + end + + test "portfolio success with cash failure updates holdings snapshot but preserves cash" do + portfolio = @item.trade_republic_accounts.create!( + kind: "portfolio", + trade_republic_account_id: "DE-PORTFOLIO", + currency: "EUR", + current_balance: BigDecimal("10.00"), + raw_positions_payload: [ { "isin" => "OLD", "quantity" => "1", "price" => "10" } ] + ) + cash = @item.trade_republic_accounts.create!( + kind: "cash", + trade_republic_account_id: "cash:DE-PORTFOLIO", + currency: "EUR", + current_balance: BigDecimal("42.00"), + cash_balance: BigDecimal("42.00") + ) + + provider = mock("trade_republic_provider") + provider.expects(:sync).returns(client_result( + "status" => "partial", + "domain_statuses" => { "account_metadata" => "success", "cash" => "failed", "portfolio" => "success", "timeline" => "success", "instrument_metadata" => "success" }, + "account" => { "brokerage_account_id" => "DE-PORTFOLIO", "currency" => "EUR" }, + "positions" => [ { "isin" => "NEW", "quantity" => "2", "price" => "20" } ], + "events" => [], + "newest_event_id" => "event-1", + "warnings" => [] + )) + + TradeRepublicItem::Importer.new(@item, provider: provider).import + + assert_equal [ "NEW" ], portfolio.reload.raw_positions_payload.map { |position| position["isin"] } + assert_equal BigDecimal("42.00"), cash.reload.current_balance + end + + test "malformed domain status never treats missing cash as an empty successful snapshot" do + cash = @item.trade_republic_accounts.create!( + kind: "cash", + trade_republic_account_id: "cash:DE-MALFORMED", + currency: "EUR", + current_balance: BigDecimal("42.00"), + cash_balance: BigDecimal("42.00") + ) + + provider = mock("trade_republic_provider") + provider.expects(:sync).returns(client_result( + "status" => "partial", + "domain_statuses" => { "account_metadata" => "success", "cash" => "failed", "portfolio" => "success", "timeline" => "success" }, + "account" => { "brokerage_account_id" => "DE-MALFORMED", "currency" => "EUR" }, + "positions" => [], "events" => [] + )) + + TradeRepublicItem::Importer.new(@item, provider: provider).import + + assert_equal BigDecimal("42.00"), cash.reload.current_balance + assert_equal BigDecimal("42.00"), cash.cash_balance + end + + test "malformed account payload preserves all existing financial data" do + account = @item.trade_republic_accounts.create!( + kind: "portfolio", + trade_republic_account_id: "DE-KEEP", + currency: "EUR", + current_balance: BigDecimal("123.45"), + raw_positions_payload: [ { "isin" => "KEEP", "quantity" => "1", "price" => "123.45" } ], + raw_timeline_payload: [ { "id" => "keep-event" } ] + ) + + provider = mock("trade_republic_provider") + provider.expects(:sync).returns(client_result( + "status" => "partial", + "domain_statuses" => { "account_metadata" => "failed", "cash" => "success", "portfolio" => "success", "timeline" => "success" }, + "cash" => { "amount" => "0", "currency" => "EUR" }, + "positions" => [], "events" => [] + )) + + assert_raises(Provider::TradeRepublicClient::MalformedResponse) do + TradeRepublicItem::Importer.new(@item, provider: provider).import + end + + account.reload + assert_equal BigDecimal("123.45"), account.current_balance + assert_equal [ "KEEP" ], account.raw_positions_payload.map { |position| position["isin"] } + assert_equal [ "keep-event" ], account.raw_timeline_payload.map { |event| event["id"] } + end + + private + + def client_result(data) + Provider::TradeRepublicClient::Result.new(data: data) + end +end diff --git a/test/models/trade_republic_item_syncer_test.rb b/test/models/trade_republic_item_syncer_test.rb new file mode 100644 index 000000000..db750be96 --- /dev/null +++ b/test/models/trade_republic_item_syncer_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class TradeRepublicItemSyncerTest < ActiveSupport::TestCase + setup do + @item = trade_republic_items(:configured_item) + end + + test "sync without session raises AuthenticationRequired and marks requires_update" do + item = trade_republic_items(:no_session_item) + sync = Sync.create!(syncable: item) + + assert_raises(Provider::TradeRepublicClient::AuthenticationRequired) do + TradeRepublicItem::Syncer.new(item).perform_sync(sync) + end + + assert item.reload.requires_update? + end + + test "authentication errors mark the item requires_update" do + sync = Sync.create!(syncable: @item) + @item.stubs(:import_latest_data).raises(Provider::TradeRepublicClient::LoginExpired, "login expired") + + assert_raises(Provider::TradeRepublicClient::LoginExpired) do + TradeRepublicItem::Syncer.new(@item).perform_sync(sync) + end + + assert @item.reload.requires_update? + assert_match(/expired/, sync.reload.sync_stats.dig("errors", 0, "message").to_s) + end + + test "successful sync imports data and collects health stats" do + sync = Sync.create!(syncable: @item) + + result = client_result( + "status" => "ok", + "session_txt" => "# refreshed", + "account" => { "brokerage_account_id" => "DESYNC1", "currency" => "EUR" }, + "cash" => { "amount" => "10.00", "currency" => "EUR" }, + "positions" => [], + "events" => [], + "newest_event_id" => nil, + "warnings" => [] + ) + + provider = mock("trade_republic_provider") + provider.expects(:sync).returns(result) + Provider::TradeRepublicClient.any_instance.stubs(:sync).returns(result) + + @item.stubs(:trade_republic_provider).returns(provider) + + TradeRepublicItem::Syncer.new(@item).perform_sync(sync) + + assert_not_nil @item.trade_republic_accounts.find_by(trade_republic_account_id: "DESYNC1") + end + + private + + def client_result(data) + Provider::TradeRepublicClient::Result.new(data: data) + end +end diff --git a/test/models/trade_republic_item_test.rb b/test/models/trade_republic_item_test.rb new file mode 100644 index 000000000..9f334964e --- /dev/null +++ b/test/models/trade_republic_item_test.rb @@ -0,0 +1,92 @@ +require "test_helper" + +class TradeRepublicItemTest < ActiveSupport::TestCase + test "database enforces one account of each kind per item" do + item = trade_republic_items(:configured_item) + + assert_raises ActiveRecord::RecordNotUnique do + item.trade_republic_accounts.create!( + kind: "portfolio", + name: "Duplicate portfolio", + currency: "EUR" + ) + end + end + + test "syncable scope requires a stored session" do + items = TradeRepublicItem.syncable + + assert_includes items, trade_republic_items(:configured_item) + assert_not_includes items, trade_republic_items(:no_session_item) + end + + test "syncable scope excludes reauthentication and pending login states" do + item = trade_republic_items(:configured_item) + item.update!(status: :requires_update) + assert_not_includes TradeRepublicItem.syncable, item + + item.update!(status: :good, pending_login_state: "pending") + assert_not_includes TradeRepublicItem.syncable, item + end + + test "session_configured? reflects stored session" do + assert_predicate trade_republic_items(:configured_item), :session_configured? + assert_not_predicate trade_republic_items(:no_session_item), :session_configured? + end + + test "credentials_configured? only needs the phone number" do + assert_predicate trade_republic_items(:no_session_item), :credentials_configured? + end + + test "a QR-authenticated item may be good without a phone number" do + item = TradeRepublicItem.new( + family: families(:dylan_family), + name: "Trade Republic QR Connection", + currency: "EUR", + status: :good, + session_blob: "qr-session" + ) + + assert_predicate item, :valid? + assert_predicate item, :credentials_configured? + end + + test "PIN is transient and is not a persisted attribute" do + item = trade_republic_items(:configured_item) + + assert_not_includes TradeRepublicItem.column_names, "pin" + item.pin = "1234" + + assert_nil item.reload.pin + end + + test "sync_status_summary counts linked and unlinked accounts" do + item = trade_republic_items(:configured_item) + item.trade_republic_accounts.destroy_all + + assert_match(/discovered yet/i, item.sync_status_summary) + assert_equal 0, item.total_accounts_count + + linked = item.trade_republic_accounts.create!( + name: "Summary Linked", + trade_republic_account_id: "DESUMM", + currency: "EUR" + ) + item.trade_republic_accounts.create!( + kind: "cash", + name: "Summary Unlinked", + trade_republic_account_id: "DESUMM2", + currency: "EUR" + ) + family_account = item.family.accounts.create!( + name: "Summary Linked Account", + balance: 0, + currency: "EUR", + accountable: Investment.new + ) + linked.ensure_account_provider!(family_account) + linked.reload + + assert_match(/1 linked, 1 need setup/i, item.reload.sync_status_summary) + end +end diff --git a/test/models/trade_republic_locale_test.rb b/test/models/trade_republic_locale_test.rb new file mode 100644 index 000000000..e7df3083f --- /dev/null +++ b/test/models/trade_republic_locale_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class TradeRepublicLocaleTest < ActiveSupport::TestCase + PLURALIZED_KEYS = %w[ + trade_republic_items.trade_republic_item.data_quality.positions + trade_republic_items.trade_republic_item.data_quality.unpriced + trade_republic_items.trade_republic_item.data_quality.events + trade_republic_items.trade_republic_item.data_quality.unknown + trade_republic_items.trade_republic_item.data_quality.expenses + trade_republic_items.complete_account_setup.success + trade_republic_items.complete_account_setup.partial_failure + ].freeze + + test "Romanian translations cover one few and other plural branches" do + assert_plural_branches(:ro, [ 1, 2, 20 ], keys: PLURALIZED_KEYS.last(2)) + end + + test "Ukrainian translations cover one few many and other plural branches" do + assert_plural_branches(:uk, [ 1, 2, 5, 1.5 ]) + end + + private + + def assert_plural_branches(locale, counts, keys: PLURALIZED_KEYS) + keys.each do |key| + counts.each do |count| + translation = I18n.t(key, locale: locale, count: count, amount: "10 EUR") + + refute_match(/translation missing/i, translation, "#{locale}.#{key} is missing count=#{count}") + assert_includes translation, count.to_s unless count == 1 + end + end + end +end diff --git a/test/models/trade_republic_session_test.rb b/test/models/trade_republic_session_test.rb new file mode 100644 index 000000000..8c11c9997 --- /dev/null +++ b/test/models/trade_republic_session_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class TradeRepublicSessionTest < ActiveSupport::TestCase + test "builds a desktop device fingerprint with a current browser version" do + session = Provider::TradeRepublicSession.new(phone_number: "+491701234567", pin: "1234") + headers = session.login_headers + payload = JSON.parse(Base64.strict_decode64(headers.fetch("X-TR-Device-Info"))) + + assert_equal "Chrome", payload.fetch("browser") + expected_browser_version = Provider::TradeRepublicSession::USER_AGENT[/Chrome\/([\d.]+)/, 1] + assert_equal expected_browser_version, payload.fetch("browserVersion") + assert_equal "Desktop", payload.fetch("device") + assert_equal "desktop", payload.fetch("deviceType") + assert payload.fetch("stableDeviceId").match?(/\A[0-9a-f]{128}\z/) + end + + test "keeps the device identity stable across session instances" do + first = Provider::TradeRepublicSession.new(phone_number: "+491701234567", pin: "1234") + second = Provider::TradeRepublicSession.new(phone_number: "+491701234567", pin: "1234") + + first_device = JSON.parse(Base64.strict_decode64(first.login_headers.fetch("X-TR-Device-Info"))) + second_device = JSON.parse(Base64.strict_decode64(second.login_headers.fetch("X-TR-Device-Info"))) + + assert_equal first_device.fetch("stableDeviceId"), second_device.fetch("stableDeviceId") + end +end