From 0cdab9a0bcb7e83a832542fc515129d6b8effdbf Mon Sep 17 00:00:00 2001 From: Aland Baban <59203362+amariwan@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:24:49 +0200 Subject: [PATCH] Add first-class Trade Republic support (#3168) * Add Trade Republic provider integration Introduce authenticated web and QR login, resilient account synchronization, deterministic financial imports, account discovery, and provider diagnostics. Keep login state encrypted, PINs transient, and incomplete provider responses non-destructive. * Address Trade Republic review findings Keep QR-authenticated sessions syncable, preserve historical holding snapshots, correct dividend direction, handle unpriced positions safely, localize repair feedback, and align provider controls with the design system. * Add Trade Republic translations for supported locales * Restore German Trade Republic account labels * Resolve remaining Trade Republic review findings * Resolve remaining Trade Republic review findings * Address latest Trade Republic review feedback * Refactor Trade Republic panel buttons to use DS::Button component and add integration tests * Fix 100x money inflation and missing positions locale key in TR views Money.new takes major units, so multiplying by 100 displayed EUR 12.34 as EUR 1234 in the holdings category cards and expense summary. Also add the pluralized holdings.index.positions key that t(".positions") resolves to (previously only defined at the unused holdings.positions root level), across all 18 locales. * fix(db): repair merge artifacts in schema and migrations - Remove duplicated icon/progress_basis columns on goals in schema.rb - Renumber Trade Republic migrations to unique versions (clashed with main's 20260824120000_add_lifecycle_to_goals) - Bump schema version to match latest migration * Address remaining Trade Republic review feedback * fix(trade-republic): address open PR #3168 review findings\n\n- Reject authenticated sessions without a securities account number so a\n blank account does not mark the item connected on a broken session.\n- Derive a missing trade amount from |quantity| x price, and a missing\n price from the resolved amount, without changing the signed import amount.\n- Regenerate db/schema.rb so the Trade Republic item/account tables and\n indexes are present; a fresh test database was otherwise missing the\n tables even though the migrations were marked up.\n * fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n) * fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n) * fix(trade-republic): add activity labels i18n keys to all locale files * Fix Trade Republic PR review follow-ups * fix(trade-republic): i18n-aware category guard and ignore generated graphify cache - Category matcher skipped core deposit/withdrawal labels; guard now compares against translated values so German etc skip correctly - Remove committed graphify-out cache and ignore dir * Protect holdings from malformed snapshots * Consolidate Trade Republic migrations * Address final Trade Republic review comments * Address final Trade Republic review comments - Remove hard-coded category matcher (merchant keyword taxonomy) and leave Trade Republic transactions uncategorized when no structured category exists; rely on Sure rules/AI - Revert shared ProviderImportAdapter# import_trade extra: param; handle Trade Republic trade metadata locally in ActivitiesProcessor via post-import Trade extra merge (preserve existing extra, deep_merge) - Preserve Trade Republic product distinctions (cash, brokerage/private_markets/interest_products/crypto_wallet via portfolio categories) without collapsing account kinds --------- Co-authored-by: Aland Baban --- Gemfile | 1 + Gemfile.lock | 1 + app/controllers/accounts_controller.rb | 4 + app/controllers/holdings_controller.rb | 21 + .../settings/providers_controller.rb | 6 + .../trade_republic_items_controller.rb | 564 ++++++++++++ app/helpers/settings_helper.rb | 3 + .../trade_republic_login_controller.js | 77 ++ .../trade_republic_qr_controller.js | 238 +++++ app/jobs/trade_republic_repair_job.rb | 24 + app/models/account.rb | 19 + app/models/data_enrichment.rb | 3 +- app/models/family.rb | 1 + .../family/trade_republic_connectable.rb | 11 + app/models/provider/metadata.rb | 1 + app/models/provider/trade_republic_adapter.rb | 59 ++ app/models/provider/trade_republic_client.rb | 859 ++++++++++++++++++ app/models/provider/trade_republic_session.rb | 179 ++++ .../provider/trade_republic_websocket.rb | 108 +++ app/models/provider_connection_status.rb | 1 + app/models/trade_republic_account.rb | 58 ++ .../activities_processor.rb | 282 ++++++ .../trade_republic_account/data_helpers.rb | 57 ++ .../holdings_processor.rb | 95 ++ .../trade_republic_account/processor.rb | 38 + .../trade_republic_client_categories.rb | 3 + app/models/trade_republic_item.rb | 214 +++++ app/models/trade_republic_item/importer.rb | 232 +++++ app/models/trade_republic_item/provided.rb | 32 + .../sync_complete_event.rb | 22 + app/models/trade_republic_item/syncer.rb | 112 +++ app/models/trade_republic_item/unlinking.rb | 40 + app/views/accounts/index.html.erb | 6 +- app/views/holdings/index.html.erb | 12 + .../providers/_trade_republic_panel.html.erb | 223 +++++ .../_connection_success.html.erb | 32 + .../_trade_republic_item.html.erb | 150 +++ .../select_existing_account.html.erb | 39 + .../setup_accounts.html.erb | 170 ++++ config/locales/views/holdings/ca.yml | 3 + config/locales/views/holdings/de.yml | 3 + config/locales/views/holdings/en.yml | 3 + config/locales/views/holdings/es.yml | 3 + config/locales/views/holdings/fr.yml | 3 + config/locales/views/holdings/hu.yml | 3 + config/locales/views/holdings/it.yml | 3 + config/locales/views/holdings/nb.yml | 3 + config/locales/views/holdings/nl.yml | 3 + config/locales/views/holdings/pl.yml | 3 + config/locales/views/holdings/pt-BR.yml | 3 + config/locales/views/holdings/ro.yml | 3 + config/locales/views/holdings/ru.yml | 3 + config/locales/views/holdings/tr.yml | 3 + config/locales/views/holdings/uk.yml | 3 + config/locales/views/holdings/vi.yml | 3 + config/locales/views/holdings/zh-CN.yml | 3 + config/locales/views/holdings/zh-TW.yml | 3 + config/locales/views/settings/de.yml | 54 ++ config/locales/views/settings/en.yml | 54 ++ .../locales/views/trade_republic_items/ca.yml | 106 +++ .../locales/views/trade_republic_items/de.yml | 120 +++ .../locales/views/trade_republic_items/en.yml | 174 ++++ .../locales/views/trade_republic_items/es.yml | 106 +++ .../locales/views/trade_republic_items/fr.yml | 106 +++ .../locales/views/trade_republic_items/hu.yml | 80 ++ .../locales/views/trade_republic_items/it.yml | 80 ++ .../locales/views/trade_republic_items/nb.yml | 80 ++ .../locales/views/trade_republic_items/nl.yml | 80 ++ .../locales/views/trade_republic_items/pl.yml | 80 ++ .../views/trade_republic_items/pt-BR.yml | 80 ++ .../locales/views/trade_republic_items/ro.yml | 82 ++ .../locales/views/trade_republic_items/ru.yml | 80 ++ .../locales/views/trade_republic_items/tr.yml | 106 +++ .../locales/views/trade_republic_items/uk.yml | 120 +++ .../locales/views/trade_republic_items/vi.yml | 106 +++ .../views/trade_republic_items/zh-CN.yml | 106 +++ .../views/trade_republic_items/zh-TW.yml | 106 +++ config/routes.rb | 23 + ...reate_trade_republic_items_and_accounts.rb | 44 + db/schema.rb | 39 + .../settings/providers_controller_test.rb | 16 + .../trade_republic_items_controller_test.rb | 187 ++++ .../trade_republic_panel_render_test.rb | 41 + test/fixtures/trade_republic_accounts.yml | 32 + test/fixtures/trade_republic_items.yml | 31 + test/i18n_test.rb | 18 + ...ublic_account_activities_processor_test.rb | 297 ++++++ ...epublic_account_holdings_processor_test.rb | 136 +++ test/models/trade_republic_client_test.rb | 405 +++++++++ .../trade_republic_item_importer_test.rb | 334 +++++++ .../models/trade_republic_item_syncer_test.rb | 61 ++ test/models/trade_republic_item_test.rb | 92 ++ test/models/trade_republic_locale_test.rb | 34 + test/models/trade_republic_session_test.rb | 26 + 94 files changed, 7771 insertions(+), 2 deletions(-) create mode 100644 app/controllers/trade_republic_items_controller.rb create mode 100644 app/javascript/controllers/trade_republic_login_controller.js create mode 100644 app/javascript/controllers/trade_republic_qr_controller.js create mode 100644 app/jobs/trade_republic_repair_job.rb create mode 100644 app/models/family/trade_republic_connectable.rb create mode 100644 app/models/provider/trade_republic_adapter.rb create mode 100644 app/models/provider/trade_republic_client.rb create mode 100644 app/models/provider/trade_republic_session.rb create mode 100644 app/models/provider/trade_republic_websocket.rb create mode 100644 app/models/trade_republic_account.rb create mode 100644 app/models/trade_republic_account/activities_processor.rb create mode 100644 app/models/trade_republic_account/data_helpers.rb create mode 100644 app/models/trade_republic_account/holdings_processor.rb create mode 100644 app/models/trade_republic_account/processor.rb create mode 100644 app/models/trade_republic_client_categories.rb create mode 100644 app/models/trade_republic_item.rb create mode 100644 app/models/trade_republic_item/importer.rb create mode 100644 app/models/trade_republic_item/provided.rb create mode 100644 app/models/trade_republic_item/sync_complete_event.rb create mode 100644 app/models/trade_republic_item/syncer.rb create mode 100644 app/models/trade_republic_item/unlinking.rb create mode 100644 app/views/settings/providers/_trade_republic_panel.html.erb create mode 100644 app/views/trade_republic_items/_connection_success.html.erb create mode 100644 app/views/trade_republic_items/_trade_republic_item.html.erb create mode 100644 app/views/trade_republic_items/select_existing_account.html.erb create mode 100644 app/views/trade_republic_items/setup_accounts.html.erb create mode 100644 config/locales/views/trade_republic_items/ca.yml create mode 100644 config/locales/views/trade_republic_items/de.yml create mode 100644 config/locales/views/trade_republic_items/en.yml create mode 100644 config/locales/views/trade_republic_items/es.yml create mode 100644 config/locales/views/trade_republic_items/fr.yml create mode 100644 config/locales/views/trade_republic_items/hu.yml create mode 100644 config/locales/views/trade_republic_items/it.yml create mode 100644 config/locales/views/trade_republic_items/nb.yml create mode 100644 config/locales/views/trade_republic_items/nl.yml create mode 100644 config/locales/views/trade_republic_items/pl.yml create mode 100644 config/locales/views/trade_republic_items/pt-BR.yml create mode 100644 config/locales/views/trade_republic_items/ro.yml create mode 100644 config/locales/views/trade_republic_items/ru.yml create mode 100644 config/locales/views/trade_republic_items/tr.yml create mode 100644 config/locales/views/trade_republic_items/uk.yml create mode 100644 config/locales/views/trade_republic_items/vi.yml create mode 100644 config/locales/views/trade_republic_items/zh-CN.yml create mode 100644 config/locales/views/trade_republic_items/zh-TW.yml create mode 100644 db/migrate/20260824200000_create_trade_republic_items_and_accounts.rb create mode 100644 test/controllers/trade_republic_items_controller_test.rb create mode 100644 test/controllers/trade_republic_panel_render_test.rb create mode 100644 test/fixtures/trade_republic_accounts.yml create mode 100644 test/fixtures/trade_republic_items.yml create mode 100644 test/models/trade_republic_account_activities_processor_test.rb create mode 100644 test/models/trade_republic_account_holdings_processor_test.rb create mode 100644 test/models/trade_republic_client_test.rb create mode 100644 test/models/trade_republic_item_importer_test.rb create mode 100644 test/models/trade_republic_item_syncer_test.rb create mode 100644 test/models/trade_republic_item_test.rb create mode 100644 test/models/trade_republic_locale_test.rb create mode 100644 test/models/trade_republic_session_test.rb 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