Files
sure/app/controllers/trade_republic_items_controller.rb
T
Aland BabanandAland Baban 0cdab9a0bc 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 <snow@iBananaMac.fritz.box>
2026-09-03 00:24:49 +02:00

565 lines
22 KiB
Ruby

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