mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Refactor TraderepublicItem sync methods and improve error handling in processor
This commit is contained in:
1
Gemfile
1
Gemfile
@@ -79,6 +79,7 @@ gem "stripe"
|
||||
gem "plaid"
|
||||
gem "snaptrade", "~> 2.0"
|
||||
gem "httparty"
|
||||
gem "websocket-client-simple"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
gem "activerecord-import"
|
||||
|
||||
491
app/controllers/traderepublic_items_controller.rb
Normal file
491
app/controllers/traderepublic_items_controller.rb
Normal file
@@ -0,0 +1,491 @@
|
||||
class TraderepublicItemsController < ApplicationController
|
||||
before_action :set_traderepublic_item, only: [ :edit, :update, :destroy, :sync, :verify_pin, :complete_login, :reauthenticate, :manual_sync ]
|
||||
|
||||
def new
|
||||
@traderepublic_item = TraderepublicItem.new(family: Current.family)
|
||||
@accountable_type = params[:accountable_type]
|
||||
@return_to = params[:return_to]
|
||||
end
|
||||
|
||||
def index
|
||||
@traderepublic_items = Current.family.traderepublic_items.includes(traderepublic_accounts: :account)
|
||||
end
|
||||
|
||||
def create
|
||||
@traderepublic_item = TraderepublicItem.new(traderepublic_item_params.merge(family: Current.family))
|
||||
@accountable_type = params[:accountable_type]
|
||||
@return_to = params[:return_to]
|
||||
|
||||
if @traderepublic_item.save
|
||||
begin
|
||||
@traderepublic_item.initiate_login!
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.update(
|
||||
"modal",
|
||||
partial: "traderepublic_items/verify_pin",
|
||||
locals: { traderepublic_item: @traderepublic_item }
|
||||
)
|
||||
end
|
||||
format.html do
|
||||
redirect_to verify_pin_traderepublic_item_path(@traderepublic_item),
|
||||
notice: t(".device_pin_sent", default: "Please check your phone for the verification PIN")
|
||||
end
|
||||
end
|
||||
rescue TraderepublicError => e
|
||||
@traderepublic_item.destroy if @traderepublic_item.persisted?
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
flash.now[:alert] = t(".login_failed", default: "Login failed: #{e.message}")
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"traderepublic-providers-panel",
|
||||
partial: "settings/providers/traderepublic_panel"
|
||||
)
|
||||
end
|
||||
format.html do
|
||||
redirect_to new_traderepublic_item_path, alert: t(".login_failed", default: "Login failed: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.turbo_stream { render :new, status: :unprocessable_entity, layout: false }
|
||||
format.html { render :new, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Manual sync: déclenche le flow PIN (initiate_login) puis popup PIN
|
||||
def manual_sync
|
||||
begin
|
||||
result = @traderepublic_item.initiate_login!
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.update(
|
||||
"modal",
|
||||
partial: "traderepublic_items/verify_pin",
|
||||
locals: { traderepublic_item: @traderepublic_item, manual_sync: true }
|
||||
)
|
||||
end
|
||||
format.html do
|
||||
redirect_to verify_pin_traderepublic_item_path(@traderepublic_item, manual_sync: true),
|
||||
notice: t(".device_pin_sent", default: "Please check your phone for the verification PIN")
|
||||
end
|
||||
end
|
||||
rescue TraderepublicError => e
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
flash.now[:alert] = t(".login_failed", default: "Manual sync failed: #{e.message}")
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"traderepublic-providers-panel",
|
||||
partial: "settings/providers/traderepublic_panel"
|
||||
)
|
||||
end
|
||||
format.html do
|
||||
redirect_to traderepublic_items_path, alert: t(".login_failed", default: "Manual sync failed: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def complete_login
|
||||
@traderepublic_item = Current.family.traderepublic_items.find(params[:id])
|
||||
device_pin = params[:device_pin]
|
||||
manual_sync = params[:manual_sync].to_s == 'true' || params[:manual_sync] == '1'
|
||||
|
||||
if device_pin.blank?
|
||||
render json: { success: false, error: t(".pin_required", default: "PIN is required") }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
success = @traderepublic_item.complete_login!(device_pin)
|
||||
if success
|
||||
if manual_sync
|
||||
# Manual sync: fetch only new tranwsactions since last transaction for each account
|
||||
@traderepublic_item.traderepublic_accounts.each do |tr_account|
|
||||
last_date = tr_account.last_transaction_date
|
||||
provider = @traderepublic_item.traderepublic_provider
|
||||
# fetch new transactions (depuis la dernière date + 1 jour pour éviter doublons)
|
||||
since = last_date ? last_date + 1.day : nil
|
||||
new_snapshot = provider.get_timeline_transactions(since: since)
|
||||
tr_account.upsert_traderepublic_transactions_snapshot!(new_snapshot)
|
||||
end
|
||||
@traderepublic_item.process_accounts
|
||||
render json: {
|
||||
success: true,
|
||||
redirect_url: settings_providers_path
|
||||
}
|
||||
else
|
||||
# Trigger initial sync synchronously to get accounts
|
||||
# Skip token refresh since we just obtained fresh tokens
|
||||
Rails.logger.info "TradeRepublic: Starting initial sync for item #{@traderepublic_item.id}"
|
||||
sync_success = @traderepublic_item.import_latest_traderepublic_data(skip_token_refresh: true)
|
||||
if sync_success
|
||||
# Check if this is a re-authentication (has linked accounts) or new connection
|
||||
has_linked_accounts = @traderepublic_item.traderepublic_accounts.joins(:account_provider).exists?
|
||||
if has_linked_accounts
|
||||
# Re-authentication: process existing accounts and redirect to settings
|
||||
Rails.logger.info "TradeRepublic: Re-authentication detected, processing existing accounts"
|
||||
@traderepublic_item.process_accounts
|
||||
render json: {
|
||||
success: true,
|
||||
redirect_url: settings_providers_path
|
||||
}
|
||||
else
|
||||
# New connection: redirect to account selection
|
||||
render json: {
|
||||
success: true,
|
||||
redirect_url: select_accounts_traderepublic_items_path(
|
||||
accountable_type: params[:accountable_type] || "Investment",
|
||||
return_to: safe_return_to_path
|
||||
)
|
||||
}
|
||||
end
|
||||
else
|
||||
render json: {
|
||||
success: false,
|
||||
error: t(".sync_failed", default: "Connection successful but failed to fetch accounts. Please try syncing manually.")
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
else
|
||||
render json: { success: false, error: t(".verification_failed", default: "PIN verification failed") }, status: :unprocessable_entity
|
||||
end
|
||||
rescue TraderepublicError => e
|
||||
Rails.logger.error "TradeRepublic PIN verification failed: \\#{e.message}"
|
||||
render json: { success: false, error: e.message }, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected error during PIN verification: \\#{e.class}: \\#{e.message}"
|
||||
render json: { success: false, error: t(".unexpected_error", default: "An unexpected error occurred") }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
# Show accounts selection after successful login
|
||||
def select_accounts
|
||||
@accountable_type = params[:accountable_type] || "Investment"
|
||||
@return_to = safe_return_to_path
|
||||
|
||||
# Find the most recent traderepublic_item with valid session
|
||||
@traderepublic_item = Current.family.traderepublic_items
|
||||
.where.not(session_token: nil)
|
||||
.where(status: :good)
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
|
||||
unless @traderepublic_item
|
||||
redirect_to new_traderepublic_item_path, alert: t(".no_active_connection", default: "No active Trade Republic connection found")
|
||||
return
|
||||
end
|
||||
|
||||
# Get available accounts
|
||||
@available_accounts = @traderepublic_item.traderepublic_accounts
|
||||
|
||||
# Filter out already linked accounts
|
||||
linked_account_ids = @available_accounts.joins(:account_provider).pluck(:id)
|
||||
@available_accounts = @available_accounts.where.not(id: linked_account_ids)
|
||||
|
||||
if @available_accounts.empty?
|
||||
if turbo_frame_request?
|
||||
@error_message = t(".no_accounts_available", default: "No Trade Republic accounts available for linking")
|
||||
@return_path = @return_to || new_account_path
|
||||
render partial: "traderepublic_items/api_error", locals: { error_message: @error_message, return_path: @return_path }, layout: false
|
||||
else
|
||||
redirect_to new_account_path, alert: t(".no_accounts_available", default: "No Trade Republic accounts available for linking")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
render layout: turbo_frame_request? ? false : "application"
|
||||
rescue => e
|
||||
Rails.logger.error "Error in select_accounts: #{e.class}: #{e.message}"
|
||||
@error_message = t(".error_loading_accounts", default: "Failed to load accounts")
|
||||
@return_path = safe_return_to_path
|
||||
render partial: "traderepublic_items/api_error",
|
||||
locals: { error_message: @error_message, return_path: @return_path },
|
||||
layout: false
|
||||
end
|
||||
|
||||
# Link selected accounts
|
||||
def link_accounts
|
||||
selected_account_ids = params[:account_ids] || []
|
||||
accountable_type = params[:accountable_type] || "Investment"
|
||||
return_to = safe_return_to_path
|
||||
|
||||
if selected_account_ids.empty?
|
||||
redirect_to new_account_path, alert: t(".no_accounts_selected", default: "No accounts selected")
|
||||
return
|
||||
end
|
||||
|
||||
traderepublic_item = Current.family.traderepublic_items
|
||||
.where.not(session_token: nil)
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
|
||||
unless traderepublic_item
|
||||
redirect_to new_account_path, alert: t(".no_connection", default: "No Trade Republic connection found")
|
||||
return
|
||||
end
|
||||
|
||||
created_accounts = []
|
||||
already_linked_accounts = []
|
||||
|
||||
selected_account_ids.each do |account_id|
|
||||
traderepublic_account = traderepublic_item.traderepublic_accounts.find_by(id: account_id)
|
||||
next unless traderepublic_account
|
||||
|
||||
# Check if already linked
|
||||
if traderepublic_account.account_provider.present?
|
||||
already_linked_accounts << traderepublic_account.name
|
||||
next
|
||||
end
|
||||
|
||||
# Create the internal Account
|
||||
# For TradeRepublic (investment accounts), we don't create an opening balance
|
||||
# because we have complete transaction history and holdings
|
||||
account = Account.new(
|
||||
family: Current.family,
|
||||
name: traderepublic_account.name,
|
||||
balance: 0, # Will be calculated from holdings and transactions
|
||||
cash_balance: 0,
|
||||
currency: traderepublic_account.currency || "EUR",
|
||||
accountable_type: accountable_type,
|
||||
accountable_attributes: {}
|
||||
)
|
||||
|
||||
Account.transaction do
|
||||
account.save!
|
||||
# Skip opening balance creation entirely for TradeRepublic accounts
|
||||
end
|
||||
|
||||
account.sync_later
|
||||
|
||||
# Link account via account_providers
|
||||
AccountProvider.create!(
|
||||
account: account,
|
||||
provider: traderepublic_account
|
||||
)
|
||||
|
||||
created_accounts << account
|
||||
end
|
||||
|
||||
if created_accounts.any?
|
||||
# Reload to pick up the newly created account_provider associations
|
||||
traderepublic_item.reload
|
||||
|
||||
# Process transactions immediately for the newly linked accounts
|
||||
# This creates Entry records from the raw transaction data
|
||||
traderepublic_item.process_accounts
|
||||
|
||||
# Trigger full sync in background to update balances and get latest data
|
||||
traderepublic_item.sync_later
|
||||
|
||||
# Redirect to the newly created account if single account, or accounts list if multiple
|
||||
# Avoid redirecting back to /accounts/new
|
||||
redirect_path = if return_to == new_account_path || return_to.blank?
|
||||
created_accounts.size == 1 ? account_path(created_accounts.first) : accounts_path
|
||||
else
|
||||
return_to
|
||||
end
|
||||
|
||||
redirect_to redirect_path, notice: t(".accounts_linked",
|
||||
count: created_accounts.count,
|
||||
default: "Successfully linked %{count} Trade Republic account(s)")
|
||||
elsif already_linked_accounts.any?
|
||||
redirect_to return_to, alert: t(".accounts_already_linked",
|
||||
default: "Selected accounts are already linked")
|
||||
else
|
||||
redirect_to new_account_path, alert: t(".no_valid_accounts", default: "No valid accounts to link")
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def update
|
||||
if @traderepublic_item.update(traderepublic_item_params)
|
||||
redirect_to traderepublic_items_path, notice: t(".updated", default: "Trade Republic connection updated successfully")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity, layout: false
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@traderepublic_item.destroy_later
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
flash.now[:notice] = t(".scheduled_for_deletion", default: "Trade Republic connection scheduled for deletion")
|
||||
render turbo_stream: [
|
||||
turbo_stream.remove("traderepublic-item-#{@traderepublic_item.id}"),
|
||||
turbo_stream.update("flash", partial: "shared/flash")
|
||||
]
|
||||
end
|
||||
format.html do
|
||||
redirect_to traderepublic_items_path, notice: t(".scheduled_for_deletion", default: "Trade Republic connection scheduled for deletion")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync
|
||||
@traderepublic_item.sync_later
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
flash.now[:notice] = t(".sync_started", default: "Sync started")
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"traderepublic-providers-panel",
|
||||
partial: "settings/providers/traderepublic_panel"
|
||||
)
|
||||
end
|
||||
format.html do
|
||||
redirect_to traderepublic_items_path, notice: t(".sync_started", default: "Sync started")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def reauthenticate
|
||||
Rails.logger.info "TradeRepublic reauthenticate action called"
|
||||
Rails.logger.info "Request format: #{request.format}"
|
||||
Rails.logger.info "Turbo frame: #{request.headers['Turbo-Frame']}"
|
||||
|
||||
begin
|
||||
result = @traderepublic_item.initiate_login!
|
||||
Rails.logger.info "Login initiated successfully"
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
Rails.logger.info "Rendering turbo_stream response"
|
||||
render turbo_stream: turbo_stream.update(
|
||||
"modal",
|
||||
partial: "traderepublic_items/verify_pin",
|
||||
locals: { traderepublic_item: @traderepublic_item }
|
||||
)
|
||||
end
|
||||
format.html do
|
||||
redirect_to verify_pin_traderepublic_item_path(@traderepublic_item),
|
||||
notice: t(".device_pin_sent", default: "Please check your phone for the verification PIN")
|
||||
end
|
||||
end
|
||||
rescue TraderepublicError => e
|
||||
Rails.logger.error "TradeRepublic re-authentication initiation failed: #{e.message}"
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
flash.now[:alert] = t(".login_failed", default: "Re-authentication failed: #{e.message}")
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"traderepublic-providers-panel",
|
||||
partial: "settings/providers/traderepublic_panel"
|
||||
)
|
||||
end
|
||||
format.html do
|
||||
redirect_to traderepublic_items_path, alert: t(".login_failed", default: "Re-authentication failed: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# For existing account linking (when adding provider to existing account)
|
||||
def select_existing_account
|
||||
begin
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to new_account_path, alert: t(".account_not_found", default: "Account not found")
|
||||
return
|
||||
end
|
||||
@accountable_type = @account.accountable_type
|
||||
|
||||
# Get the most recent traderepublic_item with valid session
|
||||
@traderepublic_item = Current.family.traderepublic_items
|
||||
.where.not(session_token: nil)
|
||||
.where(status: :good)
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
|
||||
unless @traderepublic_item
|
||||
redirect_to new_traderepublic_item_path, alert: t(".no_active_connection")
|
||||
return
|
||||
end
|
||||
|
||||
# Get available accounts (unlinked only)
|
||||
@available_accounts = @traderepublic_item.traderepublic_accounts
|
||||
.where.not(id: AccountProvider.where(provider_type: "TraderepublicAccount").select(:provider_id))
|
||||
|
||||
render layout: false
|
||||
end
|
||||
|
||||
# Link existing account
|
||||
def link_existing_account
|
||||
begin
|
||||
account = Current.family.accounts.find(params[:account_id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to new_account_path, alert: t(".account_not_found", default: "Account not found")
|
||||
return
|
||||
end
|
||||
traderepublic_account_id = params[:traderepublic_account_id]
|
||||
|
||||
if traderepublic_account_id.blank?
|
||||
redirect_to account_path(account), alert: t(".no_account_selected")
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
traderepublic_account = Current.family.traderepublic_accounts.find(traderepublic_account_id)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to new_account_path, alert: t(".traderepublic_account_not_found", default: "Trade Republic account not found")
|
||||
return
|
||||
end
|
||||
|
||||
# Check if already linked
|
||||
if traderepublic_account.account_provider.present?
|
||||
redirect_to account_path(account), alert: t(".already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
# Create the link
|
||||
AccountProvider.create!(
|
||||
account: account,
|
||||
provider: traderepublic_account
|
||||
)
|
||||
|
||||
# Trigger sync
|
||||
traderepublic_account.traderepublic_item.sync_later
|
||||
|
||||
redirect_to account_path(account), notice: t(".linked_successfully", default: "Trade Republic account linked successfully")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_traderepublic_item
|
||||
@traderepublic_item = Current.family.traderepublic_items.find(params[:id])
|
||||
end
|
||||
|
||||
def traderepublic_item_params
|
||||
params.fetch(:traderepublic_item, {}).permit(:name, :phone_number, :pin)
|
||||
end
|
||||
|
||||
def safe_return_to_path
|
||||
return_to_raw = params[:return_to].to_s
|
||||
return new_account_path if return_to_raw.blank?
|
||||
|
||||
decoded = CGI.unescape(return_to_raw)
|
||||
begin
|
||||
uri = URI.parse(decoded)
|
||||
rescue URI::InvalidURIError
|
||||
return new_account_path
|
||||
end
|
||||
|
||||
# Only allow local paths: no scheme, no host, starts with a single leading slash (not protocol-relative //)
|
||||
path = uri.path || decoded
|
||||
if uri.scheme.nil? && uri.host.nil? && path.start_with?("/") && !path.start_with?("//")
|
||||
# Rebuild path with query and fragment if present
|
||||
built = path
|
||||
built += "?#{uri.query}" if uri.query.present?
|
||||
built += "##{uri.fragment}" if uri.fragment.present?
|
||||
return built
|
||||
end
|
||||
|
||||
new_account_path
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["buttonText", "spinner"]
|
||||
|
||||
submit(event) {
|
||||
// Don't prevent default - let the form submit
|
||||
|
||||
// Show spinner and update text
|
||||
if (this.hasButtonTextTarget) {
|
||||
this.buttonTextTarget.textContent = "Sending code..."
|
||||
}
|
||||
|
||||
if (this.hasSpinnerTarget) {
|
||||
this.spinnerTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
// Disable the button to prevent double-clicks
|
||||
event.currentTarget.disabled = true
|
||||
}
|
||||
}
|
||||
8
app/jobs/traderepublic_item/sync_job.rb
Normal file
8
app/jobs/traderepublic_item/sync_job.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class TraderepublicItem::SyncJob < ApplicationJob
|
||||
queue_as :high_priority
|
||||
|
||||
def perform(sync)
|
||||
Rails.logger.info "TraderepublicItem::SyncJob: Starting sync for item \\#{sync.syncable_id} (Sync ##{sync.id})"
|
||||
sync.perform
|
||||
end
|
||||
end
|
||||
@@ -551,8 +551,9 @@ class Account::ProviderImportAdapter
|
||||
# @param external_id [String, nil] Provider's unique ID (optional, for deduplication)
|
||||
# @param source [String] Provider name
|
||||
# @param activity_label [String, nil] Investment activity label (e.g., "Buy", "Sell", "Reinvestment")
|
||||
# @param trade_type [String, nil] Optional trade type override for TradeRepublic naming
|
||||
# @return [Entry] The created entry with trade
|
||||
def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil)
|
||||
def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil, trade_type: nil)
|
||||
raise ArgumentError, "security is required" if security.nil?
|
||||
raise ArgumentError, "source is required" if source.blank?
|
||||
|
||||
@@ -561,8 +562,14 @@ class Account::ProviderImportAdapter
|
||||
trade_name = if name.present?
|
||||
name
|
||||
else
|
||||
trade_type = quantity.negative? ? "sell" : "buy"
|
||||
Trade.build_name(trade_type, quantity, security.ticker)
|
||||
# Only use trade_type if source is traderepublic and trade_type is present
|
||||
effective_type =
|
||||
if source == "traderepublic" && trade_type.present?
|
||||
trade_type
|
||||
else
|
||||
quantity.negative? ? "sell" : "buy"
|
||||
end
|
||||
Trade.build_name(effective_type, quantity, security.ticker)
|
||||
end
|
||||
|
||||
# Use find_or_initialize_by with external_id if provided, otherwise create new
|
||||
|
||||
11
app/models/concerns/traderepublic_session_configurable.rb
Normal file
11
app/models/concerns/traderepublic_session_configurable.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module TraderepublicSessionConfigurable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
def ensure_session_configured!
|
||||
raise "Session not configured" unless traderepublic_item.session_configured?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class DataEnrichment < ApplicationRecord
|
||||
belongs_to :enrichable, polymorphic: true
|
||||
|
||||
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital" }
|
||||
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", traderepublic: "traderepublic", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital" }
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Family < ApplicationRecord
|
||||
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
|
||||
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
|
||||
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, TraderepublicConnectable
|
||||
include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
|
||||
include IndexaCapitalConnectable
|
||||
|
||||
|
||||
28
app/models/family/traderepublic_connectable.rb
Normal file
28
app/models/family/traderepublic_connectable.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module Family::TraderepublicConnectable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :traderepublic_items, dependent: :destroy
|
||||
end
|
||||
|
||||
def can_connect_traderepublic?
|
||||
# Families can configure their own Trade Republic credentials
|
||||
true
|
||||
end
|
||||
|
||||
def create_traderepublic_item!(phone_number:, pin:, item_name: nil)
|
||||
traderepublic_item = traderepublic_items.create!(
|
||||
name: item_name || "Trade Republic Connection",
|
||||
phone_number: phone_number,
|
||||
pin: pin
|
||||
)
|
||||
|
||||
traderepublic_item.sync_later
|
||||
|
||||
traderepublic_item
|
||||
end
|
||||
|
||||
def has_traderepublic_credentials?
|
||||
traderepublic_items.where.not(phone_number: nil).exists?
|
||||
end
|
||||
end
|
||||
@@ -22,7 +22,32 @@ class Holding::ForwardCalculator
|
||||
current_portfolio = next_portfolio
|
||||
end
|
||||
|
||||
Holding.gapfill(holdings)
|
||||
# Also include the first date where qty = 0 for each security (position closed)
|
||||
valid_holdings = []
|
||||
holdings.group_by(&:security_id).each do |security_id, sec_holdings|
|
||||
sorted = sec_holdings.sort_by(&:date)
|
||||
prev_qty = nil
|
||||
sorted.each do |h|
|
||||
# Note: this condition (h.qty.to_f > 0 && h.amount.to_f > 0)
|
||||
# intentionally filters out holdings where quantity > 0 but amount == 0
|
||||
# (for example when price is missing or zero). If zero-amount records
|
||||
# should be treated as valid, consider falling back to a price lookup
|
||||
# or include qty>0 entries and compute amount from a known price.
|
||||
if h.qty.to_f > 0 && h.amount.to_f > 0
|
||||
valid_holdings << h
|
||||
elsif h.qty.to_f == 0
|
||||
if prev_qty.nil?
|
||||
# Allow initial zero holding (initial portfolio state)
|
||||
valid_holdings << h
|
||||
elsif prev_qty > 0
|
||||
# Add the first date where qty = 0 after a sequence of qty > 0 (position closure)
|
||||
valid_holdings << h
|
||||
end
|
||||
end
|
||||
prev_qty = h.qty.to_f
|
||||
end
|
||||
end
|
||||
Holding.gapfill(valid_holdings)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
712
app/models/provider/traderepublic.rb
Normal file
712
app/models/provider/traderepublic.rb
Normal file
@@ -0,0 +1,712 @@
|
||||
require "websocket-client-simple"
|
||||
require "json"
|
||||
|
||||
class Provider::Traderepublic
|
||||
# Batch fetch instrument details for a list of ISINs
|
||||
# Returns a hash { isin => instrument_details }
|
||||
def batch_fetch_instrument_details(isins)
|
||||
results = {}
|
||||
batch_websocket_calls do |batch|
|
||||
isins.uniq.each do |isin|
|
||||
results[isin] = batch.get_instrument_details(isin)
|
||||
end
|
||||
end
|
||||
results
|
||||
end
|
||||
# Helper: Get portfolio, cash et available_cash en un seul batch WebSocket
|
||||
def get_portfolio_and_cash_batch
|
||||
results = {}
|
||||
batch_websocket_calls do |batch|
|
||||
results[:portfolio] = batch.get_portfolio
|
||||
results[:cash] = batch.get_cash
|
||||
results[:available_cash] = batch.get_available_cash
|
||||
end
|
||||
results
|
||||
end
|
||||
# Execute several subscribe_once calls in a single WebSocket session
|
||||
# Usage: batch_websocket_calls { |batch| batch.get_portfolio; batch.get_cash }
|
||||
def batch_websocket_calls
|
||||
connect_websocket
|
||||
batch_proxy = BatchWebSocketProxy.new(self)
|
||||
yield batch_proxy
|
||||
# Optionally, small sleep to allow last messages to arrive
|
||||
sleep 0.5
|
||||
ensure
|
||||
disconnect_websocket
|
||||
end
|
||||
|
||||
# Proxy to expose only subscribe_once helpers on an open connection
|
||||
class BatchWebSocketProxy
|
||||
def initialize(provider)
|
||||
@provider = provider
|
||||
end
|
||||
|
||||
def get_portfolio
|
||||
@provider.subscribe_once("compactPortfolioByType")
|
||||
end
|
||||
|
||||
def get_cash
|
||||
@provider.subscribe_once("cash")
|
||||
end
|
||||
|
||||
def get_available_cash
|
||||
@provider.subscribe_once("availableCash")
|
||||
end
|
||||
|
||||
def get_timeline_detail(id)
|
||||
@provider.subscribe_once("timelineDetailV2", { id: id })
|
||||
end
|
||||
|
||||
def get_instrument_details(isin)
|
||||
@provider.subscribe_once("instrument", { id: isin })
|
||||
end
|
||||
|
||||
# Ajoutez ici d'autres helpers si besoin
|
||||
end
|
||||
include HTTParty
|
||||
|
||||
headers "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
||||
|
||||
HOST = "https://api.traderepublic.com".freeze
|
||||
WS_HOST = "wss://api.traderepublic.com".freeze
|
||||
WS_CONNECT_VERSION = "31".freeze
|
||||
|
||||
ECHO_INTERVAL = 30 # seconds
|
||||
WS_CONNECTION_TIMEOUT = 10 # seconds
|
||||
SESSION_VALIDATION_TIMEOUT = 7 # seconds
|
||||
|
||||
attr_reader :phone_number, :pin
|
||||
attr_accessor :session_token, :refresh_token, :raw_cookies, :process_id, :jsessionid
|
||||
|
||||
def initialize(phone_number:, pin:, session_token: nil, refresh_token: nil, raw_cookies: nil)
|
||||
@phone_number = phone_number
|
||||
@pin = pin
|
||||
@session_token = session_token
|
||||
@refresh_token = refresh_token
|
||||
@raw_cookies = raw_cookies || []
|
||||
@process_id = nil
|
||||
@jsessionid = nil
|
||||
|
||||
@ws = nil
|
||||
@subscriptions = {}
|
||||
@next_subscription_id = 1
|
||||
@echo_thread = nil
|
||||
@connected = false
|
||||
@mutex = Mutex.new
|
||||
end
|
||||
|
||||
# Authentication - Step 1: Initial login to get processId
|
||||
def initiate_login
|
||||
payload = {
|
||||
phoneNumber: @phone_number,
|
||||
pin: @pin
|
||||
}
|
||||
|
||||
Rails.logger.info "TradeRepublic: Initiating login for phone: #{@phone_number.to_s.gsub(/\d(?=\d{4})/, '*')}"
|
||||
sanitized_payload = payload.dup
|
||||
if sanitized_payload[:phoneNumber]
|
||||
sanitized_payload[:phoneNumber] = sanitized_payload[:phoneNumber].to_s.gsub(/\d(?=\d{4})/, '*')
|
||||
end
|
||||
sanitized_payload[:pin] = '[FILTERED]' if sanitized_payload.key?(:pin)
|
||||
Rails.logger.debug "TradeRepublic: Request payload: #{sanitized_payload.to_json}"
|
||||
|
||||
response = self.class.post(
|
||||
"#{HOST}/api/v1/auth/web/login",
|
||||
headers: default_headers,
|
||||
body: payload.to_json
|
||||
)
|
||||
|
||||
Rails.logger.info "TradeRepublic: Login response status: #{response.code}"
|
||||
Rails.logger.debug "TradeRepublic: Login response body: #{response.body}"
|
||||
Rails.logger.debug "TradeRepublic: Login response headers: #{response.headers.inspect}"
|
||||
|
||||
# Extract and store JSESSIONID cookie for subsequent requests
|
||||
if response.headers["set-cookie"]
|
||||
set_cookies = response.headers["set-cookie"]
|
||||
set_cookies = [set_cookies] unless set_cookies.is_a?(Array)
|
||||
set_cookies.each do |cookie|
|
||||
if cookie.start_with?("JSESSIONID=")
|
||||
@jsessionid = cookie.split(";").first
|
||||
Rails.logger.info "TradeRepublic: JSESSIONID extracted: #{@jsessionid}"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
handle_http_response(response)
|
||||
rescue => e
|
||||
Rails.logger.error "TradeRepublic: Initial login failed - #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n") if e.respond_to?(:backtrace)
|
||||
raise TraderepublicError.new("Login initiation failed: #{e.message}", :login_failed)
|
||||
end
|
||||
|
||||
# Authentication - Step 2: Verify device PIN
|
||||
def verify_device_pin(device_pin)
|
||||
raise TraderepublicError.new("No processId available", :invalid_state) unless @process_id
|
||||
|
||||
url = "#{HOST}/api/v1/auth/web/login/#{@process_id}/#{device_pin}"
|
||||
headers = default_headers
|
||||
|
||||
# Include JSESSIONID cookie if available
|
||||
if @jsessionid
|
||||
headers["Cookie"] = @jsessionid
|
||||
Rails.logger.info "TradeRepublic: Including JSESSIONID in verification request"
|
||||
end
|
||||
|
||||
Rails.logger.info "TradeRepublic: Verifying device PIN for processId: #{@process_id}"
|
||||
Rails.logger.debug "TradeRepublic: Verification URL: #{url}"
|
||||
Rails.logger.debug "TradeRepublic: Verification headers: #{headers.inspect}"
|
||||
|
||||
# IMPORTANT: Use POST, not GET!
|
||||
response = self.class.post(
|
||||
url,
|
||||
headers: headers
|
||||
)
|
||||
|
||||
Rails.logger.info "TradeRepublic: PIN verification response status: #{response.code}"
|
||||
Rails.logger.debug "TradeRepublic: PIN verification response body: #{response.body}"
|
||||
Rails.logger.debug "TradeRepublic: PIN verification response headers: #{response.headers.inspect}"
|
||||
|
||||
if response.success?
|
||||
extract_cookies_from_response(response)
|
||||
Rails.logger.info "TradeRepublic: Session token extracted: #{@session_token ? 'YES' : 'NO'}"
|
||||
Rails.logger.info "TradeRepublic: Refresh token extracted: #{@refresh_token ? 'YES' : 'NO'}"
|
||||
@session_token || raise(TraderepublicError.new("Session token not found after verification", :auth_failed))
|
||||
else
|
||||
handle_http_response(response)
|
||||
end
|
||||
rescue TraderepublicError
|
||||
raise
|
||||
rescue => e
|
||||
Rails.logger.error "TradeRepublic: Device PIN verification failed - #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n") if e.respond_to?(:backtrace)
|
||||
raise TraderepublicError.new("PIN verification failed: #{e.message}", :verification_failed)
|
||||
end
|
||||
|
||||
# Full login flow with device PIN callback
|
||||
def login(&device_pin_callback)
|
||||
return true if session_valid?
|
||||
|
||||
# Step 1: Initiate login
|
||||
result = initiate_login
|
||||
@process_id = result["processId"]
|
||||
|
||||
# Step 2: Get device PIN from user
|
||||
device_pin = device_pin_callback.call
|
||||
|
||||
# Step 3: Verify device PIN
|
||||
verify_device_pin(device_pin)
|
||||
|
||||
true
|
||||
rescue => e
|
||||
Rails.logger.error "TradeRepublic: Full login failed - #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
# Check if we have a valid session
|
||||
def session_valid?
|
||||
return false unless @session_token
|
||||
|
||||
# We'll validate by trying to connect to WebSocket
|
||||
# This is a simple check - real validation would require a test subscription
|
||||
@session_token.present?
|
||||
end
|
||||
|
||||
# Refresh session token using refresh_token
|
||||
def refresh_session
|
||||
unless @refresh_token
|
||||
Rails.logger.error "TradeRepublic: Cannot refresh session - no refresh token available"
|
||||
return false
|
||||
end
|
||||
|
||||
Rails.logger.info "TradeRepublic: Refreshing session token"
|
||||
|
||||
# Try the refresh endpoint first
|
||||
response = self.class.post(
|
||||
"#{HOST}/api/v1/auth/refresh",
|
||||
headers: default_headers.merge(cookie_header),
|
||||
body: { refreshToken: @refresh_token }.to_json
|
||||
)
|
||||
|
||||
Rails.logger.info "TradeRepublic: Token refresh response status: #{response.code}"
|
||||
Rails.logger.debug "TradeRepublic: Token refresh response body: #{response.body}"
|
||||
|
||||
if response.success?
|
||||
extract_cookies_from_response(response)
|
||||
Rails.logger.info "TradeRepublic: Session token refreshed: #{@session_token ? 'YES' : 'NO'}"
|
||||
return true
|
||||
end
|
||||
|
||||
# If refresh endpoint doesn't work (404 or error), try alternate approach
|
||||
# Some APIs require re-authentication instead of refresh
|
||||
if response.code == 404 || response.code >= 400
|
||||
Rails.logger.warn "TradeRepublic: Refresh endpoint not available (#{response.code}), re-authentication required"
|
||||
return false
|
||||
end
|
||||
|
||||
false
|
||||
rescue => e
|
||||
Rails.logger.error "TradeRepublic: Token refresh error - #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n") if e.respond_to?(:backtrace)
|
||||
false
|
||||
end
|
||||
|
||||
# WebSocket operations
|
||||
def connect_websocket
|
||||
raise "Already connected" if @ws && @ws.open?
|
||||
|
||||
# Store reference to self for use in closures
|
||||
provider = self
|
||||
|
||||
@ws = WebSocket::Client::Simple.connect(WS_HOST) do |ws|
|
||||
ws.on :open do
|
||||
Rails.logger.info "TradeRepublic: WebSocket opened"
|
||||
|
||||
# Send connect message with proper configuration
|
||||
connect_msg = {
|
||||
locale: "fr",
|
||||
platformId: "webtrading",
|
||||
platformVersion: "safari - 18.3.0",
|
||||
clientId: "app.traderepublic.com",
|
||||
clientVersion: "3.151.3"
|
||||
}
|
||||
ws.send("connect #{WS_CONNECT_VERSION} #{connect_msg.to_json}")
|
||||
Rails.logger.info "TradeRepublic: Sent connect message, waiting for confirmation..."
|
||||
end
|
||||
|
||||
ws.on :message do |msg|
|
||||
Rails.logger.debug "TradeRepublic: WebSocket received message: #{msg.data.to_s.inspect[0..200]}"
|
||||
|
||||
# Mark as connected when we receive the "connected" response
|
||||
if msg.data.start_with?("connected")
|
||||
Rails.logger.info "TradeRepublic: WebSocket confirmed connected"
|
||||
provider.instance_variable_set(:@connected, true)
|
||||
provider.send(:start_echo_thread)
|
||||
end
|
||||
|
||||
provider.send(:handle_websocket_message, msg.data)
|
||||
end
|
||||
|
||||
ws.on :close do |e|
|
||||
code = e.respond_to?(:code) ? e.code : "unknown"
|
||||
reason = e.respond_to?(:reason) ? e.reason : "unknown"
|
||||
Rails.logger.info "TradeRepublic: WebSocket closed - Code: #{code}, Reason: #{reason}"
|
||||
provider.instance_variable_set(:@connected, false)
|
||||
thread = provider.instance_variable_get(:@echo_thread)
|
||||
thread&.kill
|
||||
provider.instance_variable_set(:@echo_thread, nil)
|
||||
end
|
||||
|
||||
ws.on :error do |e|
|
||||
Rails.logger.error "TradeRepublic: WebSocket error - #{e.message}"
|
||||
provider.instance_variable_set(:@connected, false)
|
||||
end
|
||||
end
|
||||
|
||||
# Wait for connection
|
||||
wait_for_connection
|
||||
end
|
||||
|
||||
def disconnect_websocket
|
||||
return unless @ws
|
||||
|
||||
if @echo_thread
|
||||
@echo_thread.kill
|
||||
@echo_thread = nil
|
||||
end
|
||||
|
||||
if @ws.open?
|
||||
@ws.close
|
||||
end
|
||||
|
||||
@ws = nil
|
||||
@connected = false
|
||||
end
|
||||
|
||||
# Subscribe to a message type
|
||||
def subscribe(message_type, params = {}, &callback)
|
||||
raise "Not connected" unless @connected
|
||||
|
||||
sub_id = @next_subscription_id
|
||||
@next_subscription_id += 1
|
||||
|
||||
message = build_message(message_type, params)
|
||||
|
||||
@mutex.synchronize do
|
||||
@subscriptions[sub_id] = {
|
||||
type: message_type,
|
||||
callback: callback,
|
||||
message: message
|
||||
}
|
||||
end
|
||||
|
||||
send_subscription(sub_id, message)
|
||||
|
||||
sub_id
|
||||
end
|
||||
|
||||
# Unsubscribe from a subscription
|
||||
def unsubscribe(sub_id)
|
||||
@mutex.synchronize do
|
||||
@subscriptions.delete(sub_id)
|
||||
end
|
||||
|
||||
@ws&.send("unsub #{sub_id}") if @connected
|
||||
end
|
||||
|
||||
# Subscribe once (callback will be removed after first message)
|
||||
def subscribe_once(message_type, params = {})
|
||||
result = nil
|
||||
error = nil
|
||||
sub_id = subscribe(message_type, params) do |data|
|
||||
result = data
|
||||
unsubscribe(sub_id)
|
||||
end
|
||||
|
||||
# Wait for result (with timeout)
|
||||
timeout = Time.now + SESSION_VALIDATION_TIMEOUT
|
||||
while result.nil? && Time.now < timeout
|
||||
sleep 0.1
|
||||
|
||||
# Check if an error was stored in the subscription
|
||||
subscription = nil
|
||||
@mutex.synchronize do
|
||||
subscription = @subscriptions[sub_id]
|
||||
end
|
||||
if subscription && subscription[:error]
|
||||
error = subscription[:error]
|
||||
# Call unsubscribe outside the mutex (unsubscribe already synchronizes)
|
||||
unsubscribe(sub_id)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
# Raise the error if one occurred
|
||||
raise error if error
|
||||
|
||||
if result
|
||||
parsed = JSON.parse(result)
|
||||
|
||||
# Handle double-encoded JSON (some TR responses are JSON strings containing JSON)
|
||||
if parsed.is_a?(String) && (parsed.start_with?("{") || parsed.start_with?("["))
|
||||
begin
|
||||
parsed = JSON.parse(parsed)
|
||||
rescue JSON::ParserError
|
||||
# Keep as string if it's not valid JSON
|
||||
end
|
||||
end
|
||||
parsed
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Helper: Get portfolio data
|
||||
def get_portfolio
|
||||
with_websocket_connection do
|
||||
subscribe_once("compactPortfolioByType")
|
||||
end
|
||||
end
|
||||
|
||||
# Helper: Get cash data
|
||||
def get_cash
|
||||
with_websocket_connection do
|
||||
subscribe_once("cash")
|
||||
end
|
||||
end
|
||||
|
||||
# Helper: Get available cash
|
||||
def get_available_cash
|
||||
with_websocket_connection do
|
||||
subscribe_once("availableCash")
|
||||
end
|
||||
end
|
||||
|
||||
# Helper: Get timeline transactions (with automatic pagination)
|
||||
# @param since [Date, nil] Only fetch transactions after this date (for incremental sync)
|
||||
# Returns aggregated data from all pages in the same format as a single page response
|
||||
def get_timeline_transactions(since: nil)
|
||||
if since
|
||||
Rails.logger.info "TradeRepublic: Fetching timeline transactions since #{since} (incremental sync)"
|
||||
else
|
||||
Rails.logger.info "TradeRepublic: Fetching all timeline transactions (full sync)"
|
||||
end
|
||||
|
||||
all_items = []
|
||||
page_num = 1
|
||||
cursor_after = nil
|
||||
max_pages = 100 # Safety limit to prevent infinite loops
|
||||
reached_since_date = false
|
||||
|
||||
begin
|
||||
connect_websocket
|
||||
loop do
|
||||
break if page_num > max_pages
|
||||
break if reached_since_date
|
||||
|
||||
params = cursor_after ? { after: cursor_after } : {}
|
||||
response_data = subscribe_once("timelineTransactions", params)
|
||||
break unless response_data
|
||||
|
||||
items = response_data.dig("items") || []
|
||||
if since
|
||||
items_to_add = []
|
||||
items.each do |item|
|
||||
timestamp_str = item.dig("timestamp")
|
||||
if timestamp_str
|
||||
item_date = DateTime.parse(timestamp_str).to_date
|
||||
if item_date > since
|
||||
items_to_add << item
|
||||
else
|
||||
reached_since_date = true
|
||||
break
|
||||
end
|
||||
else
|
||||
items_to_add << item
|
||||
end
|
||||
end
|
||||
all_items.concat(items_to_add)
|
||||
else
|
||||
all_items.concat(items)
|
||||
end
|
||||
|
||||
break if reached_since_date
|
||||
|
||||
cursors = response_data.dig("cursors") || {}
|
||||
cursor_after = cursors["after"]
|
||||
break if cursor_after.nil? || cursor_after.empty?
|
||||
page_num += 1
|
||||
sleep 0.3
|
||||
end
|
||||
ensure
|
||||
disconnect_websocket
|
||||
end
|
||||
|
||||
# Batch fetch instrument details for all ISINs in transactions
|
||||
isins = all_items.map { |item| item["isin"] }.compact.uniq
|
||||
instrument_details = batch_fetch_instrument_details(isins) unless isins.empty?
|
||||
|
||||
# Ajoute les détails instrument à chaque transaction
|
||||
if instrument_details
|
||||
all_items.each do |item|
|
||||
isin = item["isin"]
|
||||
item["instrument_details"] = instrument_details[isin] if isin && instrument_details[isin]
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
"items" => all_items,
|
||||
"cursors" => {},
|
||||
"startingTransactionId" => nil
|
||||
}
|
||||
rescue => e
|
||||
Rails.logger.error "TradeRepublic: Failed to fetch timeline transactions - #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
nil
|
||||
end
|
||||
|
||||
# Helper: Get timeline detail
|
||||
def get_timeline_detail(id)
|
||||
with_websocket_connection do
|
||||
subscribe_once("timelineDetailV2", { id: id })
|
||||
end
|
||||
end
|
||||
|
||||
# Helper: Get instrument details (name, description, etc.) by ISIN
|
||||
def get_instrument_details(isin)
|
||||
with_websocket_connection do
|
||||
subscribe_once("instrument", { id: isin })
|
||||
end
|
||||
end
|
||||
|
||||
# Execute block with WebSocket connection
|
||||
def with_websocket_connection
|
||||
begin
|
||||
connect_websocket
|
||||
result = yield
|
||||
sleep 0.5 # Give time for any pending messages
|
||||
result
|
||||
rescue => e
|
||||
Rails.logger.error "TradeRepublic WebSocket error: #{e.message}"
|
||||
raise
|
||||
ensure
|
||||
disconnect_websocket
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_headers
|
||||
{
|
||||
"Content-Type" => "application/json",
|
||||
"Accept" => "application/json",
|
||||
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
||||
"Origin" => "https://app.traderepublic.com",
|
||||
"Referer" => "https://app.traderepublic.com/",
|
||||
"Accept-Language" => "en",
|
||||
"x-tr-platform" => "web",
|
||||
"x-tr-app-version" => "12.12.0"
|
||||
}
|
||||
end
|
||||
|
||||
def cookie_header
|
||||
return {} if @raw_cookies.nil? || @raw_cookies.empty?
|
||||
|
||||
# Join all cookies into a single Cookie header
|
||||
cookie_string = @raw_cookies.map do |cookie|
|
||||
# Extract just the name=value part before the first semicolon
|
||||
cookie.split(";").first
|
||||
end.join("; ")
|
||||
|
||||
{ "Cookie" => cookie_string }
|
||||
end
|
||||
|
||||
def extract_cookies_from_response(response)
|
||||
# Extract Set-Cookie headers
|
||||
set_cookie_headers = response.headers["set-cookie"]
|
||||
|
||||
if set_cookie_headers
|
||||
@raw_cookies = set_cookie_headers.is_a?(Array) ? set_cookie_headers : [ set_cookie_headers ]
|
||||
|
||||
# Extract session and refresh tokens
|
||||
@session_token = extract_cookie_value("tr_session")
|
||||
@refresh_token = extract_cookie_value("tr_refresh")
|
||||
end
|
||||
end
|
||||
|
||||
def extract_cookie_value(name)
|
||||
@raw_cookies.each do |cookie|
|
||||
match = cookie.match(/#{name}=([^;]+)/)
|
||||
return match[1] if match
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def wait_for_connection
|
||||
timeout = Time.now + WS_CONNECTION_TIMEOUT
|
||||
until @connected || Time.now > timeout
|
||||
sleep 0.1
|
||||
end
|
||||
|
||||
raise TraderepublicError.new("WebSocket connection timeout", :connection_timeout) unless @connected
|
||||
end
|
||||
|
||||
def start_echo_thread
|
||||
@echo_thread = Thread.new do
|
||||
loop do
|
||||
sleep ECHO_INTERVAL
|
||||
break unless @connected
|
||||
send_echo
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def send_echo
|
||||
@ws&.send("echo #{Time.now.to_i * 1000}")
|
||||
rescue => e
|
||||
Rails.logger.warn "TradeRepublic: Failed to send echo - #{e.message}"
|
||||
end
|
||||
|
||||
def handle_websocket_message(raw_message)
|
||||
return if raw_message.start_with?("echo") || raw_message.start_with?("connected")
|
||||
|
||||
parsed = parse_websocket_payload(raw_message)
|
||||
return unless parsed
|
||||
|
||||
sub_id = parsed[:subscription_id]
|
||||
json_string = parsed[:json_data]
|
||||
|
||||
begin
|
||||
data = JSON.parse(json_string)
|
||||
rescue JSON::ParserError
|
||||
Rails.logger.error "TradeRepublic: Failed to parse WebSocket message JSON"
|
||||
return
|
||||
end
|
||||
|
||||
# Check for authentication errors
|
||||
if data.is_a?(Hash) && data["errors"]
|
||||
auth_error = data["errors"].find { |err| err["errorCode"] == "AUTHENTICATION_ERROR" }
|
||||
if auth_error
|
||||
Rails.logger.error "TradeRepublic: Authentication error received - #{auth_error['errorMessage']}"
|
||||
# Store error for the subscription callback
|
||||
if sub_id && @subscriptions[sub_id]
|
||||
@subscriptions[sub_id][:error] = TraderepublicError.new(auth_error["errorMessage"] || "Unauthorized", :auth_failed)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return unless sub_id
|
||||
|
||||
subscription = @subscriptions[sub_id]
|
||||
if subscription
|
||||
begin
|
||||
# If there's an error stored, raise it
|
||||
raise subscription[:error] if subscription[:error]
|
||||
|
||||
subscription[:callback].call(json_string)
|
||||
rescue => e
|
||||
Rails.logger.error "TradeRepublic: Subscription callback error - #{e.message}"
|
||||
raise if e.is_a?(TraderepublicError) # Re-raise TraderepublicError to propagate auth failures
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def parse_websocket_payload(raw_message)
|
||||
# Find the first occurrence of { or [
|
||||
start_index_obj = raw_message.index("{")
|
||||
start_index_arr = raw_message.index("[")
|
||||
|
||||
start_index = if start_index_obj && start_index_arr
|
||||
[start_index_obj, start_index_arr].min
|
||||
elsif start_index_obj
|
||||
start_index_obj
|
||||
elsif start_index_arr
|
||||
start_index_arr
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
return nil unless start_index
|
||||
|
||||
id_part = raw_message[0...start_index].strip
|
||||
id_match = id_part.match(/\d+/)
|
||||
subscription_id = id_match ? id_match[0].to_i : nil
|
||||
|
||||
json_data = raw_message[start_index..-1].strip
|
||||
|
||||
{ subscription_id: subscription_id, json_data: json_data }
|
||||
end
|
||||
|
||||
def build_message(type, params = {})
|
||||
{ type: type, token: @session_token }.merge(params)
|
||||
end
|
||||
|
||||
def send_subscription(sub_id, message)
|
||||
payload = "sub #{sub_id} #{message.to_json}"
|
||||
@ws.send(payload)
|
||||
end
|
||||
|
||||
def handle_http_response(response)
|
||||
Rails.logger.error "TradeRepublic: HTTP response code=#{response.code}, body=#{response.body}"
|
||||
|
||||
case response.code
|
||||
when 200
|
||||
JSON.parse(response.body)
|
||||
when 400
|
||||
raise TraderepublicError.new("Bad request: #{response.body}", :bad_request)
|
||||
when 401
|
||||
raise TraderepublicError.new("Invalid credentials", :unauthorized)
|
||||
when 403
|
||||
raise TraderepublicError.new("Access forbidden", :forbidden)
|
||||
when 404
|
||||
raise TraderepublicError.new("Resource not found", :not_found)
|
||||
when 429
|
||||
raise TraderepublicError.new("Rate limit exceeded", :rate_limit_exceeded)
|
||||
when 500..599
|
||||
raise TraderepublicError.new("Server error: #{response.code}", :server_error)
|
||||
else
|
||||
raise TraderepublicError.new("Unexpected response: #{response.code}", :unexpected_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
89
app/models/provider/traderepublic_adapter.rb
Normal file
89
app/models/provider/traderepublic_adapter.rb
Normal file
@@ -0,0 +1,89 @@
|
||||
class Provider::TraderepublicAdapter < Provider::Base
|
||||
include Provider::Syncable
|
||||
include Provider::InstitutionMetadata
|
||||
|
||||
# Register this adapter with the factory
|
||||
Provider::Factory.register("TraderepublicAccount", self)
|
||||
|
||||
# Define which account types this provider supports
|
||||
def self.supported_account_types
|
||||
%w[Investment Depository]
|
||||
end
|
||||
|
||||
# Returns connection configurations for this provider
|
||||
def self.connection_configs(family:)
|
||||
return [] unless family.can_connect_traderepublic?
|
||||
|
||||
[ {
|
||||
key: "traderepublic",
|
||||
name: I18n.t("traderepublic_items.provider_name", default: "Trade Republic"),
|
||||
description: I18n.t("traderepublic_items.provider_description", default: "Connect to your Trade Republic account"),
|
||||
can_connect: true,
|
||||
new_account_path: ->(accountable_type, return_to) {
|
||||
Rails.application.routes.url_helpers.select_accounts_traderepublic_items_path(
|
||||
accountable_type: accountable_type,
|
||||
return_to: return_to
|
||||
)
|
||||
},
|
||||
existing_account_path: ->(account_id) {
|
||||
Rails.application.routes.url_helpers.select_existing_account_traderepublic_items_path(
|
||||
account_id: account_id
|
||||
)
|
||||
}
|
||||
} ]
|
||||
end
|
||||
|
||||
def provider_name
|
||||
"traderepublic"
|
||||
end
|
||||
|
||||
# Build a Trade Republic provider instance with family-specific credentials
|
||||
# @param family [Family] The family to get credentials for (required)
|
||||
# @return [Provider::Traderepublic, nil] Returns nil if credentials are not configured
|
||||
def self.build_provider(family: nil)
|
||||
return nil unless family.present?
|
||||
|
||||
# Get family-specific credentials
|
||||
traderepublic_item = family.traderepublic_items.where.not(phone_number: nil).first
|
||||
return nil unless traderepublic_item&.credentials_configured?
|
||||
|
||||
Provider::Traderepublic.new(
|
||||
phone_number: traderepublic_item.phone_number,
|
||||
pin: traderepublic_item.pin
|
||||
)
|
||||
end
|
||||
|
||||
def sync_path
|
||||
Rails.application.routes.url_helpers.sync_traderepublic_item_path(item)
|
||||
end
|
||||
|
||||
def item
|
||||
provider_account.traderepublic_item
|
||||
end
|
||||
|
||||
def can_delete_holdings?
|
||||
false
|
||||
end
|
||||
|
||||
def institution_domain
|
||||
"traderepublic.com"
|
||||
end
|
||||
|
||||
def institution_name
|
||||
I18n.t("traderepublic_items.provider_name", default: "Trade Republic")
|
||||
end
|
||||
|
||||
def institution_url
|
||||
"https://traderepublic.com"
|
||||
end
|
||||
|
||||
def institution_color
|
||||
"#00D69E"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_account
|
||||
@provider_account ||= TraderepublicAccount.find(@account_provider.provider_id)
|
||||
end
|
||||
end
|
||||
39
app/models/trade_republic/security_resolver.rb
Normal file
39
app/models/trade_republic/security_resolver.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
# Centralizes logic for resolving, associating, or creating a Security for TradeRepublic
|
||||
class TradeRepublic::SecurityResolver
|
||||
def initialize(isin, name: nil, ticker: nil, mic: nil)
|
||||
@isin = isin&.strip&.upcase
|
||||
@name = name
|
||||
@ticker = ticker
|
||||
@mic = mic
|
||||
end
|
||||
|
||||
# Returns the existing Security or creates a new one if not found
|
||||
def resolve
|
||||
Rails.logger.info "TradeRepublic::SecurityResolver - Resolve called: ISIN=#{@isin.inspect}, name=#{@name.inspect}, ticker=#{@ticker.inspect}, mic=#{@mic.inspect}"
|
||||
return nil unless @isin.present?
|
||||
|
||||
# Search for an exact ISIN match in the name
|
||||
security = Security.where("name LIKE ?", "%#{@isin}%").first
|
||||
if security
|
||||
Rails.logger.info "TradeRepublic::SecurityResolver - Security found by ISIN in name: id=#{security.id}, ISIN=#{@isin}, name=#{security.name.inspect}, ticker=#{security.ticker.inspect}, mic=#{security.exchange_operating_mic.inspect}"
|
||||
return security
|
||||
end
|
||||
|
||||
# Create a new Security if none found
|
||||
name = @name.present? ? @name : "Security #{@isin}"
|
||||
name = "#{name} (#{@isin})" unless name.include?(@isin)
|
||||
begin
|
||||
security = Security.create!(name: name, ticker: @ticker, exchange_operating_mic: @mic)
|
||||
Rails.logger.info "TradeRepublic::SecurityResolver - Security created: id=#{security.id}, ISIN=#{@isin}, ticker=#{@ticker}, mic=#{@mic}, name=#{name.inspect}"
|
||||
security
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
if e.message.include?("Ticker has already been taken")
|
||||
existing = Security.where(ticker: @ticker, exchange_operating_mic: @mic).first
|
||||
Rails.logger.warn "TradeRepublic::SecurityResolver - Duplicate ticker/mic, returning existing: id=#{existing&.id}, ticker=#{@ticker}, mic=#{@mic}"
|
||||
return existing if existing
|
||||
end
|
||||
raise
|
||||
end
|
||||
end
|
||||
end
|
||||
72
app/models/traderepublic_account.rb
Normal file
72
app/models/traderepublic_account.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
class TraderepublicAccount < ApplicationRecord
|
||||
# Stocke le snapshot brut du compte (portfolio)
|
||||
def upsert_traderepublic_snapshot!(account_snapshot)
|
||||
self.raw_payload = account_snapshot
|
||||
save!
|
||||
end
|
||||
belongs_to :traderepublic_item
|
||||
has_one :account_provider, as: :provider, dependent: :destroy
|
||||
has_one :linked_account, through: :account_provider, source: :account
|
||||
|
||||
# Stocke le snapshot brut des transactions (timeline enrichie)
|
||||
def upsert_traderepublic_transactions_snapshot!(transactions_snapshot)
|
||||
Rails.logger.info "TraderepublicAccount #{id}: upsert_traderepublic_transactions_snapshot! - snapshot keys=#{transactions_snapshot.is_a?(Hash) ? transactions_snapshot.keys : transactions_snapshot.class}"
|
||||
Rails.logger.info "TraderepublicAccount \\#{id}: upsert_traderepublic_transactions_snapshot! - snapshot preview=\\#{transactions_snapshot.inspect[0..300]}"
|
||||
|
||||
# If the new snapshot is nil or empty, do not overwrite existing payload
|
||||
if transactions_snapshot.nil? || (transactions_snapshot.respond_to?(:empty?) && transactions_snapshot.empty?)
|
||||
Rails.logger.info "TraderepublicAccount #{id}: Received empty transactions snapshot, skipping overwrite."
|
||||
return
|
||||
end
|
||||
|
||||
# If this is the first import or there is no existing payload, just set it
|
||||
if self.raw_transactions_payload.nil? || (self.raw_transactions_payload.respond_to?(:empty?) && self.raw_transactions_payload.empty?)
|
||||
self.raw_transactions_payload = transactions_snapshot
|
||||
save!
|
||||
return
|
||||
end
|
||||
|
||||
# Merge/append new transactions to existing payload (assuming array of items under 'items' key)
|
||||
existing = self.raw_transactions_payload
|
||||
new_data = transactions_snapshot
|
||||
|
||||
# Support both Hash and Array structures (prefer Hash with 'items')
|
||||
existing_items = if existing.is_a?(Hash) && existing["items"].is_a?(Array)
|
||||
existing["items"]
|
||||
elsif existing.is_a?(Array)
|
||||
existing
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
new_items = if new_data.is_a?(Hash) && new_data["items"].is_a?(Array)
|
||||
new_data["items"]
|
||||
elsif new_data.is_a?(Array)
|
||||
new_data
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# Only append items that are not already present (by id if available)
|
||||
existing_ids = existing_items.map { |i| i["id"] }.compact
|
||||
items_to_add = new_items.reject { |i| i["id"] && existing_ids.include?(i["id"]) }
|
||||
|
||||
merged_items = existing_items + items_to_add
|
||||
|
||||
# Rebuild the payload in the same structure as before
|
||||
merged_payload = if existing.is_a?(Hash)
|
||||
existing.merge("items" => merged_items)
|
||||
else
|
||||
merged_items
|
||||
end
|
||||
|
||||
self.raw_transactions_payload = merged_payload
|
||||
save!
|
||||
end
|
||||
|
||||
# Pour compatibilité avec l'importer
|
||||
def last_transaction_date
|
||||
return nil unless linked_account && linked_account.transactions.any?
|
||||
linked_account.transactions.order(date: :desc).limit(1).pick(:date)
|
||||
end
|
||||
end
|
||||
593
app/models/traderepublic_account/processor.rb
Normal file
593
app/models/traderepublic_account/processor.rb
Normal file
@@ -0,0 +1,593 @@
|
||||
class TraderepublicAccount::Processor
|
||||
attr_reader :traderepublic_account
|
||||
|
||||
def initialize(traderepublic_account)
|
||||
@traderepublic_account = traderepublic_account
|
||||
end
|
||||
|
||||
def process
|
||||
account = traderepublic_account.linked_account
|
||||
return unless account
|
||||
|
||||
# Wrap deletions in a transaction so trades and Entry deletions succeed or roll back together
|
||||
Account.transaction do
|
||||
if account.respond_to?(:trades)
|
||||
deleted_count = account.trades.delete_all
|
||||
Rails.logger.info "TraderepublicAccount::Processor - #{deleted_count} trades for account ##{account.id} deleted before reprocessing."
|
||||
end
|
||||
|
||||
Entry.where(account_id: account.id, source: "traderepublic").delete_all
|
||||
Rails.logger.info "TraderepublicAccount::Processor - All Entry records for account ##{account.id} deleted before reprocessing."
|
||||
end
|
||||
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Processing account #{account.id}"
|
||||
|
||||
# Process transactions from raw payload
|
||||
process_transactions(account)
|
||||
|
||||
# Process holdings from raw payload (calculate, then persist)
|
||||
begin
|
||||
Holding::Materializer.new(account, strategy: :forward).materialize_holdings
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Holdings calculated and persisted."
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicAccount::Processor - Error calculating/persisting holdings: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
end
|
||||
|
||||
# Persist balances using Balance::Materializer (strategy: :forward)
|
||||
begin
|
||||
Balance::Materializer.new(account, strategy: :forward).materialize_balances
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Balances calculated and persisted."
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicAccount::Processor - Error in Balance::Materializer: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
end
|
||||
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Finished processing account #{account.id}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_transactions(account)
|
||||
transactions_data = traderepublic_account.raw_transactions_payload
|
||||
return unless transactions_data
|
||||
|
||||
Rails.logger.info "[TR Processor] transactions_data loaded: #{transactions_data.class}"
|
||||
|
||||
# Extract items array from the payload structure
|
||||
# Try both Hash and Array formats
|
||||
items = if transactions_data.is_a?(Hash)
|
||||
transactions_data["items"]
|
||||
elsif transactions_data.is_a?(Array)
|
||||
transactions_data.find { |pair| pair[0] == "items" }&.last
|
||||
end
|
||||
|
||||
return unless items.is_a?(Array)
|
||||
|
||||
Rails.logger.info "[TR Processor] items array size: #{items.size}"
|
||||
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Processing #{items.size} transactions"
|
||||
|
||||
items.each do |txn|
|
||||
Rails.logger.info "[TR Processor] Processing txn id=#{txn['id']}"
|
||||
process_single_transaction(account, txn)
|
||||
end
|
||||
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Finished processing transactions"
|
||||
end
|
||||
|
||||
def process_single_transaction(account, txn)
|
||||
# Skip if deleted or hidden
|
||||
if txn["deleted"]
|
||||
Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (deleted)"
|
||||
return
|
||||
end
|
||||
if txn["hidden"]
|
||||
Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (hidden)"
|
||||
return
|
||||
end
|
||||
unless txn["status"] == "EXECUTED"
|
||||
Rails.logger.info "[TR Processor] Skipping txn id=#{txn['id']} (status=#{txn['status']})"
|
||||
return
|
||||
end
|
||||
|
||||
# Parse basic data
|
||||
traderepublic_id = txn["id"]
|
||||
title = txn["title"]
|
||||
subtitle = txn["subtitle"]
|
||||
amount_data = txn["amount"] || {}
|
||||
amount = amount_data["value"]
|
||||
currency = amount_data["currency"] || "EUR"
|
||||
timestamp = txn["timestamp"]
|
||||
|
||||
unless traderepublic_id && timestamp && amount
|
||||
Rails.logger.info "[TR Processor] Skipping txn: missing traderepublic_id, timestamp, or amount (id=#{txn['id']})"
|
||||
return
|
||||
end
|
||||
|
||||
# Trade Republic sends negative values for expenses (Buys) and positive values for income (Sells).
|
||||
# Sure expects negative = income and positive = expense, so we invert the sign here.
|
||||
amount = -amount.to_f
|
||||
|
||||
# Parse date
|
||||
begin
|
||||
date = Time.parse(timestamp).to_date
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "TraderepublicAccount::Processor - Failed to parse timestamp #{timestamp.inspect} for txn #{traderepublic_id}: #{e.class}: #{e.message}. Falling back to Date.today"
|
||||
date = Date.today
|
||||
end
|
||||
|
||||
# Check if this is a trade (Buy/Sell Order)
|
||||
# Note: subtitle contains the trade type info that becomes 'notes' after import
|
||||
is_trade_result = is_trade?(subtitle)
|
||||
|
||||
Rails.logger.info "TradeRepublic: Processing '#{title}' | Subtitle: '#{subtitle}' | is_trade?: #{is_trade_result}"
|
||||
|
||||
if is_trade_result
|
||||
Rails.logger.info "[TR Processor] Transaction id=#{traderepublic_id} is a trade."
|
||||
process_trade(traderepublic_id, title, subtitle, amount, currency, date, txn)
|
||||
else
|
||||
Rails.logger.info "[TR Processor] Transaction id=#{traderepublic_id} is NOT a trade. Importing as cash transaction."
|
||||
# Import cash transactions (dividends, interest, transfers)
|
||||
import_adapter.import_transaction(
|
||||
external_id: traderepublic_id,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: title,
|
||||
source: "traderepublic",
|
||||
notes: subtitle
|
||||
)
|
||||
end
|
||||
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Imported: #{title} (#{subtitle}) - #{amount} #{currency}"
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicAccount::Processor - Error processing transaction #{txn['id']}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
end
|
||||
|
||||
def is_trade?(text)
|
||||
return false unless text
|
||||
text_lower = text.downcase
|
||||
# Support multiple languages and variations
|
||||
# Manual orders:
|
||||
# French: Ordre d'achat, Ordre de vente, Ordre d'achat sur stop
|
||||
# English: Buy order, Sell order
|
||||
# German: Kauforder, Verkaufsorder
|
||||
# Savings plans (automatic recurring purchases):
|
||||
# French: Plan d'épargne exécuté
|
||||
# English: Savings plan executed
|
||||
# German: Sparplan ausgeführt
|
||||
text_lower.match?(/ordre d'achat|ordre de vente|buy order|sell order|kauforder|verkaufsorder|plan d'épargne exécuté|savings plan executed|sparplan ausgeführt/)
|
||||
end
|
||||
|
||||
def process_trade(external_id, title, subtitle, amount, currency, date, txn)
|
||||
# Extraire ISIN depuis l'icon (toujours présent)
|
||||
isin = extract_isin(txn["icon"])
|
||||
Rails.logger.info "[TR Processor] process_trade: extracted ISIN=#{isin.inspect} from icon for txn id=#{external_id}"
|
||||
|
||||
# 1. Chercher dans trade_details (détail transaction)
|
||||
trade_details = txn["trade_details"] || {}
|
||||
quantity_str = nil
|
||||
price_str = nil
|
||||
isin_str = nil
|
||||
|
||||
# Extraction robuste depuis trade_details['sections'] (niveau 1 et imbriqué)
|
||||
if trade_details.is_a?(Hash) && trade_details["sections"].is_a?(Array)
|
||||
trade_details["sections"].each do |section|
|
||||
# Cas direct (niveau 1, Transaction)
|
||||
if section["type"] == "table" && section["title"] == "Transaction" && section["data"].is_a?(Array)
|
||||
section["data"].each do |row|
|
||||
case row["title"]
|
||||
when "Titres", "Actions"
|
||||
quantity_str ||= row.dig("detail", "text")
|
||||
when "Cours du titre", "Prix du titre"
|
||||
price_str ||= row.dig("detail", "text")
|
||||
end
|
||||
end
|
||||
end
|
||||
# Cas direct (niveau 1, tout table)
|
||||
if section["type"] == "table" && section["data"].is_a?(Array)
|
||||
section["data"].each do |row|
|
||||
case row["title"]
|
||||
when "Actions"
|
||||
quantity_str ||= row.dig("detail", "text")
|
||||
when "Prix du titre"
|
||||
price_str ||= row.dig("detail", "text")
|
||||
end
|
||||
# Cas imbriqué : row["title"] == "Transaction" && row["detail"]["action"]["payload"]["sections"]
|
||||
if row["title"] == "Transaction" && row.dig("detail", "action", "payload", "sections").is_a?(Array)
|
||||
row["detail"]["action"]["payload"]["sections"].each do |sub_section|
|
||||
next unless sub_section["type"] == "table" && sub_section["data"].is_a?(Array)
|
||||
sub_section["data"].each do |sub_row|
|
||||
case sub_row["title"]
|
||||
when "Actions", "Titres"
|
||||
quantity_str ||= sub_row.dig("detail", "text")
|
||||
when "Prix du titre", "Cours du titre"
|
||||
price_str ||= sub_row.dig("detail", "text")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Fallback : champs directs
|
||||
quantity_str ||= txn["quantity"] || txn["qty"]
|
||||
price_str ||= txn["price"] || txn["price_per_unit"]
|
||||
|
||||
# ISIN : on garde la logique précédente
|
||||
isin_str = nil
|
||||
if trade_details.is_a?(Hash) && trade_details["sections"].is_a?(Array)
|
||||
trade_details["sections"].each do |section|
|
||||
if section["data"].is_a?(Hash) && section["data"]["icon"]
|
||||
possible_isin = extract_isin(section["data"]["icon"])
|
||||
isin_str ||= possible_isin if possible_isin
|
||||
end
|
||||
end
|
||||
end
|
||||
isin = isin_str if isin_str.present?
|
||||
|
||||
Rails.logger.info "TradeRepublic: Processing trade #{title}"
|
||||
Rails.logger.info "TradeRepublic: Values - Qty: #{quantity_str}, Price: #{price_str}, ISIN: #{isin_str || isin}"
|
||||
Rails.logger.info "[TR Processor] process_trade: after details, ISIN=#{isin.inspect}, quantity_str=#{quantity_str.inspect}, price_str=#{price_str.inspect}"
|
||||
|
||||
# Correction : s'assurer que le subtitle utilisé est bien celui du trade (issu de txn["subtitle"] si besoin)
|
||||
effective_subtitle = subtitle.presence || txn["subtitle"]
|
||||
# Détermine le type d'opération (buy/sell)
|
||||
op_type = nil
|
||||
if effective_subtitle.to_s.downcase.match?(/sell|vente|verkauf/)
|
||||
op_type = "sell"
|
||||
elsif effective_subtitle.to_s.downcase.match?(/buy|achat|kauf/)
|
||||
op_type = "buy"
|
||||
end
|
||||
|
||||
quantity = parse_quantity(quantity_str) if quantity_str
|
||||
quantity = -quantity if quantity && op_type == "sell"
|
||||
price = parse_price(price_str) if price_str
|
||||
|
||||
# Extract ticker and mic from instrument_details if available
|
||||
instrument_data = txn["instrument_details"]
|
||||
ticker = nil
|
||||
mic = nil
|
||||
if instrument_data.present?
|
||||
ticker_mic_pairs = extract_ticker_and_mic(instrument_data, isin)
|
||||
if ticker_mic_pairs.any?
|
||||
ticker, mic = ticker_mic_pairs.first
|
||||
end
|
||||
end
|
||||
|
||||
# Si on n'a pas de quantité ou de prix, fallback transaction simple
|
||||
if isin && quantity.nil? && amount && amount != 0
|
||||
Rails.logger.warn "TradeRepublic: Cannot extract quantity/price for trade #{external_id} (#{title})"
|
||||
Rails.logger.warn "TradeRepublic: Importing as transaction instead of trade"
|
||||
Rails.logger.info "[TR Processor] process_trade: skipping trade creation for txn id=#{external_id} (missing quantity or price)"
|
||||
import_adapter.import_transaction(
|
||||
external_id: external_id,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: title,
|
||||
source: "traderepublic",
|
||||
notes: subtitle
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
# Créer le trade si toutes les infos sont là
|
||||
if isin && quantity && price
|
||||
Rails.logger.info "[TR Processor] process_trade: ready to call find_or_create_security for ISIN=#{isin.inspect}, title=#{title.inspect}, ticker=#{ticker.inspect}, mic=#{mic.inspect}"
|
||||
security = find_or_create_security(isin, title, ticker, mic)
|
||||
if security
|
||||
Rails.logger.info "[TR Processor] process_trade: got security id=#{security.id} for ISIN=#{isin}"
|
||||
Rails.logger.info "[TR Processor] TRADE IMPORT: external_id=#{external_id} qty=#{quantity} security_id=#{security.id} isin=#{isin} ticker=#{ticker} mic=#{mic} op_type=#{op_type}"
|
||||
import_adapter.import_trade(
|
||||
external_id: external_id,
|
||||
security: security,
|
||||
quantity: quantity,
|
||||
price: price,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: "#{title} - #{subtitle}",
|
||||
source: "traderepublic",
|
||||
trade_type: op_type
|
||||
)
|
||||
return
|
||||
else
|
||||
Rails.logger.error "[TR Processor] process_trade: find_or_create_security returned nil for ISIN=#{isin}"
|
||||
Rails.logger.error "TradeRepublic: Could not create security for ISIN #{isin}"
|
||||
end
|
||||
end
|
||||
|
||||
# Fallback : transaction simple
|
||||
Rails.logger.warn "TradeRepublic: Falling back to transaction for #{external_id}: ISIN=#{isin}, Qty=#{quantity}, Price=#{price}"
|
||||
Rails.logger.info "[TR Processor] process_trade: fallback to cash transaction for txn id=#{external_id}"
|
||||
import_adapter.import_transaction(
|
||||
external_id: external_id,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: title,
|
||||
source: "traderepublic",
|
||||
notes: subtitle
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
def extract_all_data(obj, result = {})
|
||||
case obj
|
||||
when Hash
|
||||
# Check if this hash looks like a data item with title/detail
|
||||
if obj["title"] && obj["detail"] && obj["detail"].is_a?(Hash) && obj["detail"]["text"]
|
||||
result[obj["title"]] = obj["detail"]["text"]
|
||||
end
|
||||
|
||||
# Recursively process all values
|
||||
obj.each do |key, value|
|
||||
extract_all_data(value, result)
|
||||
end
|
||||
when Array
|
||||
obj.each do |item|
|
||||
extract_all_data(item, result)
|
||||
end
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def parse_quantity(quantity_str)
|
||||
# quantity_str format: "3 Shares" or "0.01 BTC"
|
||||
return nil unless quantity_str
|
||||
|
||||
token = quantity_str.to_s.split.first
|
||||
cleaned = token.to_s.gsub(/[^0-9.,\-+]/, "")
|
||||
return nil if cleaned.blank?
|
||||
|
||||
begin
|
||||
Float(cleaned.tr(",", ".")).abs
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_price(price_str)
|
||||
# price_str format: "€166.70" or "$500.00" - extract numeric substring and parse strictly
|
||||
return nil unless price_str
|
||||
|
||||
match = price_str.to_s.match(/[+\-]?\d+(?:[.,]\d+)*/)
|
||||
return nil unless match
|
||||
|
||||
cleaned = match[0].tr(",", ".")
|
||||
begin
|
||||
Float(cleaned)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def extract_isin(isin_or_icon)
|
||||
return nil unless isin_or_icon
|
||||
|
||||
# If it's already an ISIN (12 characters)
|
||||
return isin_or_icon if isin_or_icon.match?(/^[A-Z]{2}[A-Z0-9]{9}\d$/)
|
||||
|
||||
# Extract from icon path: "logos/US0378331005/v2"
|
||||
match = isin_or_icon.match(%r{logos/([A-Z]{2}[A-Z0-9]{9}\d)/})
|
||||
match ? match[1] : nil
|
||||
end
|
||||
|
||||
def find_or_create_security(isin, fallback_name = nil, ticker = nil, mic = nil)
|
||||
# Always use string and upcase safely
|
||||
safe_isin = isin.to_s.upcase
|
||||
safe_ticker = ticker.to_s.upcase if ticker
|
||||
safe_mic = mic.to_s.upcase if mic
|
||||
resolved = TradeRepublic::SecurityResolver.new(safe_isin, name: fallback_name, ticker: safe_ticker, mic: safe_mic).resolve
|
||||
return resolved if resolved
|
||||
Rails.logger.error "TradeRepublic: SecurityResolver n'a pas pu trouver ou créer de security pour ISIN=#{safe_isin}, name=#{fallback_name}, ticker=#{safe_ticker}, mic=#{safe_mic}"
|
||||
nil
|
||||
end
|
||||
|
||||
# fetch_trade_details et fetch_instrument_details supprimés : tout est lu depuis raw_transactions_payload
|
||||
|
||||
def extract_security_name(instrument_data)
|
||||
return nil unless instrument_data.is_a?(Hash)
|
||||
|
||||
# Trade Republic returns instrument details with the name in different possible locations:
|
||||
# 1. Direct name field
|
||||
# 2. First exchange's nameAtExchange (most common for stocks/ETFs)
|
||||
# 3. shortName or typeNameAtExchange for other instruments
|
||||
|
||||
# Try direct name fields first
|
||||
name = instrument_data["name"] ||
|
||||
instrument_data["shortName"] ||
|
||||
instrument_data["typeNameAtExchange"]
|
||||
|
||||
# If no direct name, try getting from first active exchange
|
||||
if name.blank? && instrument_data["exchanges"].is_a?(Array)
|
||||
active_exchange = instrument_data["exchanges"].find { |ex| ex["active"] == true }
|
||||
exchange = active_exchange || instrument_data["exchanges"].first
|
||||
name = exchange["nameAtExchange"] if exchange
|
||||
end
|
||||
|
||||
name&.strip
|
||||
end
|
||||
|
||||
# Returns an Array of [ticker, mic] pairs ordered by relevance (active exchanges first)
|
||||
def extract_ticker_and_mic(instrument_data, isin)
|
||||
return [[isin, nil]] unless instrument_data.is_a?(Hash)
|
||||
|
||||
exchanges = instrument_data["exchanges"]
|
||||
return [[isin, nil]] unless exchanges.is_a?(Array) && exchanges.any?
|
||||
|
||||
# Order exchanges by active first, then the rest in their provided order
|
||||
ordered = exchanges.partition { |ex| ex["active"] == true }.flatten
|
||||
|
||||
pairs = ordered.map do |ex|
|
||||
ticker = ex["symbolAtExchange"] || ex["symbol"]
|
||||
mic = ex["slug"] || ex["mic"] || ex["mic_code"]
|
||||
ticker = isin if ticker.blank?
|
||||
ticker = clean_ticker(ticker)
|
||||
[ticker, mic]
|
||||
end
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
pairs.map { |t, m| [t, m] }.uniq
|
||||
end
|
||||
|
||||
def clean_ticker(ticker)
|
||||
return ticker unless ticker
|
||||
|
||||
# Remove common suffixes
|
||||
# Examples: "AAPL.US" -> "AAPL", "BTCEUR.SPOT" -> "BTC/EUR" (keep as is for crypto)
|
||||
cleaned = ticker.strip
|
||||
|
||||
# Don't clean if it looks like a crypto pair (contains /)
|
||||
return cleaned if cleaned.include?("/")
|
||||
|
||||
# Remove .SPOT, .US, etc.
|
||||
cleaned = cleaned.split(".").first if cleaned.include?(".")
|
||||
|
||||
cleaned
|
||||
end
|
||||
|
||||
def process_holdings(account)
|
||||
payload = traderepublic_account.raw_payload
|
||||
return unless payload.is_a?(Hash)
|
||||
|
||||
# The payload is wrapped in a 'raw' key by the Importer
|
||||
portfolio_data = payload["raw"] || payload
|
||||
|
||||
positions = extract_positions(portfolio_data)
|
||||
|
||||
if positions.empty?
|
||||
Rails.logger.info "TraderepublicAccount::Processor - No positions found in payload."
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Calculating holdings from trades..."
|
||||
|
||||
# Calculate holdings from trades using ForwardCalculator
|
||||
begin
|
||||
calculated_holdings = Holding::ForwardCalculator.new(account).calculate
|
||||
# Importer tous les holdings calculés, y compris qty = 0 (pour refléter la fermeture de position)
|
||||
if calculated_holdings.any?
|
||||
Holding.import!(calculated_holdings, on_duplicate_key_update: {
|
||||
conflict_target: [ :account_id, :security_id, :date, :currency ],
|
||||
columns: [ :qty, :price, :amount, :updated_at ]
|
||||
})
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Saved #{calculated_holdings.size} calculated holdings (no filter)"
|
||||
else
|
||||
Rails.logger.info "TraderepublicAccount::Processor - No holdings calculated from trades"
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicAccount::Processor - Error calculating holdings from trades: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Processing #{positions.size} holdings"
|
||||
|
||||
positions.each do |pos|
|
||||
process_single_holding(account, pos)
|
||||
end
|
||||
end
|
||||
|
||||
def extract_positions(portfolio_data)
|
||||
return [] unless portfolio_data.is_a?(Hash)
|
||||
|
||||
# Try to find categories in different places
|
||||
# Sometimes the payload is directly the array of categories? No, usually it's an object.
|
||||
# But sometimes it's nested in 'payload'
|
||||
|
||||
categories = []
|
||||
|
||||
if portfolio_data["categories"].is_a?(Array)
|
||||
categories = portfolio_data["categories"]
|
||||
elsif portfolio_data.dig("payload", "categories").is_a?(Array)
|
||||
categories = portfolio_data.dig("payload", "categories")
|
||||
elsif portfolio_data["payload"].is_a?(Hash) && portfolio_data["payload"]["categories"].is_a?(Array)
|
||||
categories = portfolio_data["payload"]["categories"]
|
||||
end
|
||||
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Categories type: #{categories.class}"
|
||||
if categories.is_a?(Array)
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Categories count: #{categories.size}"
|
||||
if categories.empty?
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Portfolio data keys: #{portfolio_data.keys}"
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Payload keys: #{portfolio_data['payload'].keys}" if portfolio_data['payload'].is_a?(Hash)
|
||||
end
|
||||
categories.each_with_index do |cat, idx|
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Category #{idx} keys: #{cat.keys rescue 'not a hash'}"
|
||||
if cat.is_a?(Hash) && cat["positions"]
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Category #{idx} positions type: #{cat['positions'].class}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
positions = []
|
||||
categories.each do |category|
|
||||
next unless category["positions"].is_a?(Array)
|
||||
category["positions"].each { |p| positions << p }
|
||||
end
|
||||
positions
|
||||
end
|
||||
|
||||
def process_single_holding(account, pos)
|
||||
isin = pos["isin"]
|
||||
name = pos["name"]
|
||||
quantity = pos["netSize"].to_f
|
||||
|
||||
# Try to find current value
|
||||
# Trade Republic usually sends 'netValue' for the total current value of the position
|
||||
amount = pos["netValue"]&.to_f
|
||||
|
||||
# Cost basis
|
||||
avg_buy_in = pos["averageBuyIn"]&.to_f
|
||||
cost_basis = avg_buy_in ? (quantity * avg_buy_in) : nil
|
||||
|
||||
return unless isin && quantity
|
||||
|
||||
if amount.nil?
|
||||
Rails.logger.warn "TraderepublicAccount::Processor - Holding #{isin} missing netValue. Keys: #{pos.keys}"
|
||||
return
|
||||
end
|
||||
|
||||
security = find_or_create_security(isin, name)
|
||||
return unless security
|
||||
|
||||
price = quantity.zero? ? 0 : (amount / quantity)
|
||||
|
||||
# Prefer position currency if present, else fall back to linked account currency or account default, then final fallback to EUR
|
||||
currency = pos["currency"] || traderepublic_account.linked_account&.currency || traderepublic_account.linked_account&.default_currency || "EUR"
|
||||
|
||||
import_adapter.import_holding(
|
||||
security: security,
|
||||
quantity: quantity,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: Date.today,
|
||||
price: price,
|
||||
cost_basis: cost_basis,
|
||||
source: "traderepublic",
|
||||
external_id: isin,
|
||||
account_provider_id: traderepublic_account.account_provider&.id
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicAccount::Processor - Error processing holding #{pos['isin']}: #{e.message}"
|
||||
end
|
||||
|
||||
def update_balance(account)
|
||||
balance = traderepublic_account.current_balance
|
||||
return unless balance
|
||||
|
||||
Rails.logger.info "TraderepublicAccount::Processor - Updating balance to #{balance}"
|
||||
|
||||
# Update account balance
|
||||
account.update(balance: balance)
|
||||
end
|
||||
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(traderepublic_account.linked_account)
|
||||
end
|
||||
end
|
||||
9
app/models/traderepublic_error.rb
Normal file
9
app/models/traderepublic_error.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# Custom error class for Trade Republic
|
||||
class TraderepublicError < StandardError
|
||||
attr_reader :error_code
|
||||
|
||||
def initialize(message, error_code = :unknown_error)
|
||||
super(message)
|
||||
@error_code = error_code
|
||||
end
|
||||
end
|
||||
237
app/models/traderepublic_item.rb
Normal file
237
app/models/traderepublic_item.rb
Normal file
@@ -0,0 +1,237 @@
|
||||
|
||||
class TraderepublicItem < ApplicationRecord
|
||||
include Syncable, Provided
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good, prefix: true
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :phone_number, deterministic: true
|
||||
encrypts :pin, deterministic: true
|
||||
encrypts :session_token # non-deterministic (default)
|
||||
encrypts :refresh_token # non-deterministic (default)
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
validates :phone_number, presence: true, on: :create
|
||||
validates :phone_number, format: { with: /\A\+\d{10,15}\z/, message: "must be in international format (e.g., +491234567890)" }, on: :create, if: :phone_number_changed?
|
||||
validates :pin, presence: { message: I18n.t("traderepublic_items.errors.pin_required", default: "PIN is required") }, on: :create
|
||||
|
||||
belongs_to :family
|
||||
has_one_attached :logo
|
||||
|
||||
has_many :traderepublic_accounts, dependent: :destroy
|
||||
has_many :accounts, through: :traderepublic_accounts
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :needs_update, -> { where(status: :requires_update) }
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
def import_latest_traderepublic_data(skip_token_refresh: false, sync: nil)
|
||||
provider = traderepublic_provider
|
||||
unless provider
|
||||
Rails.logger.error "TraderepublicItem #{id} - Cannot import: TradeRepublic provider is not configured (missing credentials)"
|
||||
raise StandardError.new(I18n.t("traderepublic_items.errors.provider_not_configured", default: "TradeRepublic provider is not configured"))
|
||||
end
|
||||
|
||||
# Try import with current tokens
|
||||
TraderepublicItem::Importer.new(self, traderepublic_provider: provider).import
|
||||
rescue TraderepublicError => e
|
||||
# If authentication failed and we have credentials, try re-authenticating automatically
|
||||
if [:unauthorized, :auth_failed].include?(e.error_code) && !skip_token_refresh && credentials_configured?
|
||||
Rails.logger.warn "TraderepublicItem #{id} - Authentication failed, attempting automatic re-authentication"
|
||||
|
||||
if auto_reauthenticate
|
||||
Rails.logger.info "TraderepublicItem #{id} - Re-authentication successful, retrying import"
|
||||
# Retry import with fresh tokens (skip_token_refresh to avoid infinite loop)
|
||||
return import_latest_traderepublic_data(skip_token_refresh: true)
|
||||
else
|
||||
Rails.logger.error "TraderepublicItem #{id} - Automatic re-authentication failed"
|
||||
update!(status: :requires_update)
|
||||
raise StandardError.new("Session expired and automatic re-authentication failed. Please log in again manually.")
|
||||
end
|
||||
else
|
||||
Rails.logger.error "TraderepublicItem #{id} - Failed to import data: #{e.message}"
|
||||
raise
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicItem #{id} - Failed to import data: #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
def credentials_configured?
|
||||
phone_number.present? && pin.present?
|
||||
end
|
||||
|
||||
def session_configured?
|
||||
session_token.present?
|
||||
end
|
||||
|
||||
def traderepublic_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
@traderepublic_provider ||= Provider::Traderepublic.new(
|
||||
phone_number: phone_number,
|
||||
pin: pin,
|
||||
session_token: session_token,
|
||||
refresh_token: refresh_token,
|
||||
raw_cookies: session_cookies
|
||||
)
|
||||
end
|
||||
|
||||
# Initiate login and store processId
|
||||
def initiate_login!
|
||||
provider = Provider::Traderepublic.new(
|
||||
phone_number: phone_number,
|
||||
pin: pin
|
||||
)
|
||||
|
||||
result = provider.initiate_login
|
||||
update!(
|
||||
process_id: result["processId"],
|
||||
session_cookies: { jsessionid: provider.jsessionid }.compact
|
||||
)
|
||||
result
|
||||
end
|
||||
|
||||
# Complete login with device PIN
|
||||
def complete_login!(device_pin)
|
||||
raise I18n.t("traderepublic_items.errors.no_process_id", default: "No processId found") unless process_id
|
||||
|
||||
provider = Provider::Traderepublic.new(
|
||||
phone_number: phone_number,
|
||||
pin: pin
|
||||
)
|
||||
provider.process_id = process_id
|
||||
provider.jsessionid = session_cookies&.dig("jsessionid") if session_cookies.is_a?(Hash)
|
||||
|
||||
provider.verify_device_pin(device_pin)
|
||||
|
||||
# Save session data
|
||||
update!(
|
||||
session_token: provider.session_token,
|
||||
refresh_token: provider.refresh_token,
|
||||
session_cookies: provider.raw_cookies,
|
||||
process_id: nil, # Clear processId after successful login
|
||||
status: :good
|
||||
)
|
||||
|
||||
true
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicItem #{id}: Login failed - #{e.message}"
|
||||
update!(status: :requires_update)
|
||||
false
|
||||
end
|
||||
|
||||
# Check if login needs to be completed
|
||||
def pending_login?
|
||||
process_id.present? && session_token.blank?
|
||||
end
|
||||
|
||||
# Automatic re-authentication when tokens expire
|
||||
# Trade Republic doesn't support token refresh, so we need to re-authenticate from scratch
|
||||
def auto_reauthenticate
|
||||
Rails.logger.info "TraderepublicItem #{id}: Starting automatic re-authentication"
|
||||
|
||||
unless credentials_configured?
|
||||
Rails.logger.error "TraderepublicItem #{id}: Cannot auto re-authenticate - credentials not configured"
|
||||
return false
|
||||
end
|
||||
|
||||
begin
|
||||
# Step 1: Initiate login to get processId
|
||||
result = initiate_login!
|
||||
|
||||
Rails.logger.info "TraderepublicItem #{id}: Login initiated, processId: #{process_id}"
|
||||
|
||||
# Trade Republic requires SMS verification - we can't auto-complete this step
|
||||
# Mark as requires_update so user knows they need to re-authenticate
|
||||
Rails.logger.warn "TraderepublicItem #{id}: SMS verification required - automatic re-authentication cannot proceed"
|
||||
update!(status: :requires_update)
|
||||
|
||||
false
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicItem #{id}: Automatic re-authentication failed - #{e.message}"
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def syncer
|
||||
@syncer ||= TraderepublicItem::Syncer.new(self)
|
||||
end
|
||||
|
||||
def process_accounts
|
||||
# Process each account's transactions and create entries
|
||||
traderepublic_accounts.includes(:linked_account).each do |tr_account|
|
||||
next unless tr_account.linked_account
|
||||
|
||||
TraderepublicAccount::Processor.new(tr_account).process
|
||||
end
|
||||
end
|
||||
|
||||
def schedule_account_syncs(parent_sync:, window_start_date: nil, window_end_date: nil)
|
||||
# Trigger balance calculations for linked accounts
|
||||
traderepublic_accounts.joins(:account).merge(Account.visible).each do |tr_account|
|
||||
tr_account.linked_account.sync_later(
|
||||
parent_sync: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Enqueue a sync for this item
|
||||
def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
sync = Sync.create!(
|
||||
syncable: self,
|
||||
parent: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
TraderepublicItem::SyncJob.perform_later(sync)
|
||||
sync
|
||||
end
|
||||
|
||||
# Perform sync using the Sync pattern
|
||||
def perform_sync(sync)
|
||||
sync.start! if sync.may_start?
|
||||
begin
|
||||
provider = traderepublic_provider
|
||||
unless provider
|
||||
sync.fail!
|
||||
sync.update(error: I18n.t("traderepublic_items.errors.provider_not_configured", default: "TradeRepublic provider is not configured"))
|
||||
return false
|
||||
end
|
||||
importer = TraderepublicItem::Importer.new(self, traderepublic_provider: provider)
|
||||
success = importer.import
|
||||
if success
|
||||
sync.complete!
|
||||
return true
|
||||
else
|
||||
sync.fail!
|
||||
sync.update(error: "Import failed")
|
||||
return false
|
||||
end
|
||||
rescue => e
|
||||
sync.fail!
|
||||
sync.update(error: e.message)
|
||||
Rails.logger.error "TraderepublicItem #{id} - perform_sync failed: #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
296
app/models/traderepublic_item/importer.rb
Normal file
296
app/models/traderepublic_item/importer.rb
Normal file
@@ -0,0 +1,296 @@
|
||||
|
||||
class TraderepublicItem::Importer
|
||||
include TraderepublicSessionConfigurable
|
||||
attr_reader :traderepublic_item, :provider
|
||||
|
||||
# Utility to find or create a security by ISIN, otherwise by ticker/MIC
|
||||
def find_or_create_security_from_tr(position_or_txn)
|
||||
isin = position_or_txn["isin"]&.strip&.upcase.presence
|
||||
ticker = position_or_txn["ticker"]&.strip.presence || position_or_txn["symbol"]&.strip.presence
|
||||
mic = position_or_txn["exchange_operating_mic"]&.strip.presence || position_or_txn["mic"]&.strip.presence
|
||||
name = position_or_txn["name"]&.strip.presence
|
||||
|
||||
TradeRepublic::SecurityResolver.new(isin, name: name, ticker: ticker, mic: mic).resolve
|
||||
end
|
||||
|
||||
def initialize(traderepublic_item, traderepublic_provider: nil)
|
||||
@traderepublic_item = traderepublic_item
|
||||
@provider = traderepublic_provider || traderepublic_item.traderepublic_provider
|
||||
end
|
||||
|
||||
def import
|
||||
|
||||
raise "Provider not configured" unless provider
|
||||
ensure_session_configured!
|
||||
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Starting import"
|
||||
|
||||
# Import portfolio and create/update accounts
|
||||
import_portfolio
|
||||
|
||||
# Import timeline transactions
|
||||
import_transactions
|
||||
|
||||
# Mark sync as successful
|
||||
traderepublic_item.update!(status: :good)
|
||||
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Import completed successfully"
|
||||
|
||||
true
|
||||
rescue TraderepublicError => e
|
||||
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Import failed - #{e.message}"
|
||||
|
||||
# Mark as requires_update if authentication error
|
||||
if [ :unauthorized, :auth_failed ].include?(e.error_code)
|
||||
traderepublic_item.update!(status: :requires_update)
|
||||
raise e # Re-raise so the caller can handle re-auth
|
||||
end
|
||||
|
||||
false
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Import failed - #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def import_portfolio
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching portfolio data"
|
||||
|
||||
portfolio_data = provider.get_portfolio
|
||||
cash_data = provider.get_cash
|
||||
|
||||
parsed_portfolio = if portfolio_data
|
||||
portfolio_data.is_a?(String) ? JSON.parse(portfolio_data) : portfolio_data
|
||||
else
|
||||
{}
|
||||
end
|
||||
|
||||
parsed_cash = if cash_data
|
||||
cash_data.is_a?(String) ? JSON.parse(cash_data) : cash_data
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
# Get or create main account
|
||||
account = find_or_create_main_account(parsed_portfolio)
|
||||
|
||||
# Update account with portfolio data
|
||||
update_account_with_portfolio(account, parsed_portfolio, parsed_cash)
|
||||
|
||||
# Import holdings/positions
|
||||
import_holdings(account, parsed_portfolio)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse portfolio data - #{e.message}"
|
||||
end
|
||||
|
||||
def import_transactions
|
||||
|
||||
begin
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Fetching transactions"
|
||||
|
||||
# Find main account
|
||||
account = traderepublic_item.traderepublic_accounts.first
|
||||
return unless account
|
||||
|
||||
# Get the date of the last synced transaction for incremental sync
|
||||
since_date = account.last_transaction_date
|
||||
# Force a full sync if no transaction actually exists
|
||||
if account.linked_account.nil? || !account.linked_account.transactions.exists?
|
||||
since_date = nil
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Forcing initial full sync (no transactions exist)"
|
||||
elsif since_date
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Incremental sync from #{since_date}"
|
||||
else
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Initial full sync"
|
||||
end
|
||||
|
||||
transactions_data = provider.get_timeline_transactions(since: since_date)
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data class=#{transactions_data.class} keys=#{transactions_data.respond_to?(:keys) ? transactions_data.keys : 'n/a'}"
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: transactions_data preview=#{transactions_data.inspect[0..300]}"
|
||||
return unless transactions_data
|
||||
|
||||
parsed = transactions_data.is_a?(String) ? JSON.parse(transactions_data) : transactions_data
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: parsed class=#{parsed.class} keys=#{parsed.respond_to?(:keys) ? parsed.keys : 'n/a'}"
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: parsed preview=#{parsed.inspect[0..300]}"
|
||||
|
||||
# Add instrument details for each transaction (if ISIN present)
|
||||
items = if parsed.is_a?(Hash)
|
||||
parsed["items"]
|
||||
elsif parsed.is_a?(Array)
|
||||
pair = parsed.find { |p| p[0] == "items" }
|
||||
pair ? pair[1] : nil
|
||||
end
|
||||
|
||||
|
||||
if items.is_a?(Array)
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: items count before enrichment = #{items.size}"
|
||||
items.each do |txn|
|
||||
# Enrich with instrument_details (ISIN) if possible
|
||||
isin = txn["isin"]
|
||||
isin ||= txn.dig("instrument", "isin")
|
||||
isin ||= extract_isin_from_icon(txn["icon"])
|
||||
if isin.present? && isin.match?(/^[A-Z]{2}[A-Z0-9]{10}$/)
|
||||
begin
|
||||
instrument_details = provider.get_instrument_details(isin)
|
||||
txn["instrument_details"] = instrument_details if instrument_details.present?
|
||||
rescue => e
|
||||
Rails.logger.warn "TraderepublicItem #{traderepublic_item.id}: Failed to fetch instrument details for ISIN #{isin} - #{e.message}"
|
||||
end
|
||||
end
|
||||
# Enrich with trade_details (timelineDetailV2) for each transaction
|
||||
begin
|
||||
trade_details = provider.get_timeline_detail(txn["id"])
|
||||
txn["trade_details"] = trade_details if trade_details.present?
|
||||
rescue => e
|
||||
Rails.logger.warn "TraderepublicItem #{traderepublic_item.id}: Failed to fetch trade details for txn #{txn["id"]} - #{e.message}"
|
||||
end
|
||||
end
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: items count after enrichment = #{items.size}"
|
||||
end
|
||||
|
||||
|
||||
|
||||
# Detailed log before saving the snapshot
|
||||
items_count = items.is_a?(Array) ? items.size : 0
|
||||
preview = items.is_a?(Array) && items_count > 0 ? items.first(2).map { |i| i.slice('id', 'title', 'isin') } : items.inspect
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Transactions snapshot contains #{items_count} items (with instrument details). Preview: #{preview}"
|
||||
|
||||
|
||||
# Update account with transactions data
|
||||
account.upsert_traderepublic_transactions_snapshot!(parsed)
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Snapshot saved with #{items_count} items."
|
||||
|
||||
# Process transactions
|
||||
process_transactions(account, parsed)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Failed to parse transactions - #{e.message}"
|
||||
rescue => e
|
||||
Rails.logger.error "TraderepublicItem #{traderepublic_item.id}: Unexpected error in import_transactions - #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n") if e.respond_to?(:backtrace)
|
||||
raise
|
||||
end
|
||||
|
||||
def find_or_create_main_account(portfolio_data)
|
||||
# TradeRepublic typically has one main account
|
||||
account = traderepublic_item.traderepublic_accounts.first_or_initialize(
|
||||
account_id: "main",
|
||||
name: "Trade Republic",
|
||||
currency: "EUR"
|
||||
)
|
||||
|
||||
account.save! if account.new_record?
|
||||
account
|
||||
end
|
||||
|
||||
def update_account_with_portfolio(account, portfolio_data, cash_data = nil)
|
||||
# Extract cash/balance from portfolio if available
|
||||
cash_value = extract_cash_value(portfolio_data, cash_data)
|
||||
|
||||
account.upsert_traderepublic_snapshot!({
|
||||
id: "main",
|
||||
name: "Trade Republic",
|
||||
currency: "EUR",
|
||||
balance: cash_value,
|
||||
status: "active",
|
||||
type: "investment",
|
||||
raw: portfolio_data
|
||||
})
|
||||
end
|
||||
|
||||
def extract_cash_value(portfolio_data, cash_data = nil)
|
||||
# Try to extract cash value from cash_data first
|
||||
if cash_data.is_a?(Array) && cash_data.first.is_a?(Hash)
|
||||
# [{"accountNumber"=>"...", "currencyId"=>"EUR", "amount"=>1064.3}]
|
||||
return cash_data.first["amount"]
|
||||
end
|
||||
|
||||
# Try to extract cash value from portfolio structure
|
||||
# This depends on the actual API response structure
|
||||
return 0 unless portfolio_data.is_a?(Hash)
|
||||
|
||||
# Common patterns in trading APIs
|
||||
portfolio_data.dig("cash", "value") ||
|
||||
portfolio_data.dig("availableCash") ||
|
||||
portfolio_data.dig("balance") ||
|
||||
0
|
||||
end
|
||||
|
||||
def import_holdings(account, portfolio_data)
|
||||
positions = extract_positions(portfolio_data)
|
||||
return if positions.empty?
|
||||
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Processing #{positions.size} positions"
|
||||
|
||||
linked_account = account.linked_account
|
||||
return unless linked_account
|
||||
|
||||
positions.each do |position|
|
||||
security = find_or_create_security_from_tr(position)
|
||||
holding_date = position["date"] || Date.current # fallback to today if nil
|
||||
next unless holding_date.present?
|
||||
holding = Holding.find_or_initialize_by(
|
||||
account: linked_account,
|
||||
security: security,
|
||||
date: holding_date,
|
||||
currency: position["currency"]
|
||||
)
|
||||
holding.qty = position["quantity"]
|
||||
holding.price = position["price"]
|
||||
holding.save!
|
||||
end
|
||||
end
|
||||
|
||||
def extract_positions(portfolio_data)
|
||||
return [] unless portfolio_data.is_a?(Hash)
|
||||
|
||||
# Extract positions based on the Portfolio interface structure
|
||||
categories = portfolio_data["categories"] || []
|
||||
|
||||
positions = []
|
||||
categories.each do |category|
|
||||
next unless category["positions"].is_a?(Array)
|
||||
|
||||
category["positions"].each do |position|
|
||||
positions << position
|
||||
end
|
||||
end
|
||||
|
||||
positions
|
||||
end
|
||||
|
||||
def extract_isin_from_icon(icon)
|
||||
return nil unless icon.is_a?(String)
|
||||
match = icon.match(%r{logos/([A-Z]{2}[A-Z0-9]{9}\d)/})
|
||||
match ? match[1] : nil
|
||||
end
|
||||
|
||||
def process_transactions(account, transactions_data)
|
||||
return unless transactions_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "TraderepublicItem #{traderepublic_item.id}: Processing #{transactions_data.size} transactions"
|
||||
|
||||
linked_account = account.linked_account
|
||||
return unless linked_account
|
||||
|
||||
trades = []
|
||||
transactions_data.each do |txn|
|
||||
security = find_or_create_security_from_tr(txn)
|
||||
trade = Trade.create!(
|
||||
account: linked_account,
|
||||
security: security,
|
||||
qty: txn["quantity"],
|
||||
price: txn["price"],
|
||||
date: txn["date"],
|
||||
currency: txn["currency"]
|
||||
)
|
||||
if block_given?
|
||||
yield trade
|
||||
else
|
||||
trades << trade
|
||||
end
|
||||
end
|
||||
trades unless block_given?
|
||||
end
|
||||
end
|
||||
12
app/models/traderepublic_item/provided.rb
Normal file
12
app/models/traderepublic_item/provided.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module TraderepublicItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def traderepublic_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
Provider::Traderepublic.new(
|
||||
phone_number: phone_number,
|
||||
pin: pin
|
||||
)
|
||||
end
|
||||
end
|
||||
10
app/models/traderepublic_item/sync_complete_event.rb
Normal file
10
app/models/traderepublic_item/sync_complete_event.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class TraderepublicItem::SyncCompleteEvent
|
||||
def initialize(traderepublic_item)
|
||||
@traderepublic_item = traderepublic_item
|
||||
end
|
||||
|
||||
def broadcast
|
||||
# Placeholder - add any post-sync broadcasts here if needed
|
||||
Rails.logger.info "TraderepublicItem::SyncCompleteEvent - Sync completed for item #{@traderepublic_item.id}"
|
||||
end
|
||||
end
|
||||
91
app/models/traderepublic_item/syncer.rb
Normal file
91
app/models/traderepublic_item/syncer.rb
Normal file
@@ -0,0 +1,91 @@
|
||||
class TraderepublicItem::Syncer
|
||||
attr_reader :traderepublic_item
|
||||
|
||||
def initialize(traderepublic_item)
|
||||
@traderepublic_item = traderepublic_item
|
||||
end
|
||||
|
||||
def perform_sync(sync)
|
||||
# Phase 1: Check session status
|
||||
unless traderepublic_item.session_configured?
|
||||
Rails.logger.error "TraderepublicItem::Syncer - No session configured for item #{traderepublic_item.id}"
|
||||
traderepublic_item.update!(status: :requires_update)
|
||||
sync.update!(status_text: "Login required") if sync.respond_to?(:status_text)
|
||||
return
|
||||
end
|
||||
|
||||
# Phase 2: Import data from TradeRepublic API
|
||||
sync.update!(status_text: "Importing portfolio from Trade Republic...") if sync.respond_to?(:status_text)
|
||||
|
||||
begin
|
||||
traderepublic_item.import_latest_traderepublic_data(sync: sync)
|
||||
rescue TraderepublicError => e
|
||||
Rails.logger.error "TraderepublicItem::Syncer - Import failed: #{e.message}"
|
||||
|
||||
# Mark as requires_update if authentication error
|
||||
if [ :unauthorized, :auth_failed ].include?(e.error_code)
|
||||
traderepublic_item.update!(status: :requires_update)
|
||||
sync.update!(status_text: "Authentication failed - login required") if sync.respond_to?(:status_text)
|
||||
else
|
||||
sync.update!(status_text: "Import failed: #{e.message}") if sync.respond_to?(:status_text)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
# Phase 3: Check account setup status and collect sync statistics
|
||||
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
|
||||
total_accounts = traderepublic_item.traderepublic_accounts.count
|
||||
linked_accounts = traderepublic_item.traderepublic_accounts.joins(:linked_account).merge(Account.visible)
|
||||
unlinked_accounts = traderepublic_item.traderepublic_accounts.includes(:linked_account).where(accounts: { id: nil })
|
||||
|
||||
# Store sync statistics for display
|
||||
sync_stats = {
|
||||
total_accounts: total_accounts,
|
||||
linked_accounts: linked_accounts.count,
|
||||
unlinked_accounts: unlinked_accounts.count
|
||||
}
|
||||
|
||||
# Set pending_account_setup if there are unlinked accounts
|
||||
if unlinked_accounts.any?
|
||||
traderepublic_item.update!(pending_account_setup: true)
|
||||
sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text)
|
||||
else
|
||||
traderepublic_item.update!(pending_account_setup: false)
|
||||
end
|
||||
|
||||
# Phase 4: Process transactions for linked accounts only
|
||||
if linked_accounts.any?
|
||||
sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text)
|
||||
Rails.logger.info "TraderepublicItem::Syncer - Processing #{linked_accounts.count} linked accounts (appel Processor sur chaque compte)"
|
||||
traderepublic_item.process_accounts
|
||||
Rails.logger.info "TraderepublicItem::Syncer - Finished processing accounts"
|
||||
|
||||
# Phase 5: Schedule balance calculations for linked accounts
|
||||
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
|
||||
traderepublic_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
else
|
||||
Rails.logger.info "TraderepublicItem::Syncer - No linked accounts to process (Importer utilisé uniquement à l'import initial)"
|
||||
end
|
||||
|
||||
# Store sync statistics in the sync record for status display
|
||||
if sync.respond_to?(:sync_stats)
|
||||
sync.update!(sync_stats: sync_stats)
|
||||
end
|
||||
|
||||
# Recalculate holdings for all linked accounts
|
||||
linked_accounts.each do |traderepublic_account|
|
||||
account = traderepublic_account.linked_account
|
||||
next unless account
|
||||
Rails.logger.info "TraderepublicItem::Syncer - Recalculating holdings for account #{account.id}"
|
||||
Holding::Materializer.new(account, strategy: :forward).materialize_holdings
|
||||
end
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
# no-op for now
|
||||
end
|
||||
end
|
||||
102
app/views/settings/providers/_traderepublic_panel.html.erb
Normal file
102
app/views/settings/providers/_traderepublic_panel.html.erb
Normal file
@@ -0,0 +1,102 @@
|
||||
<div class="space-y-6">
|
||||
<div class="text-sm text-secondary space-y-2">
|
||||
<p><strong><%= t("settings.providers.traderepublic_panel.important", default: "Important:") %></strong> <%= t("settings.providers.traderepublic_panel.intro", default: "Trade Republic integration uses an unofficial API and requires 2-factor authentication.") %></p>
|
||||
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li><%= t("settings.providers.traderepublic_panel.need_phone_pin", default: "You'll need your phone number and Trade Republic PIN") %></li>
|
||||
<li><%= t("settings.providers.traderepublic_panel.verification_code", default: "A verification code will be sent to your Trade Republic app during setup") %></li>
|
||||
<li><%= t("settings.providers.traderepublic_panel.sessions_stored", default: "Sessions are stored securely and will need to be refreshed periodically") %></li>
|
||||
</ul>
|
||||
|
||||
<div class="p-3 bg-warning/10 border border-warning rounded-lg mt-3">
|
||||
<p class="text-xs text-warning">
|
||||
<%= icon "alert-triangle", class: "inline-block w-4 h-4 mr-1" %>
|
||||
<strong><%= t("settings.providers.traderepublic_panel.note", default: "Note:") %></strong> <%= t("settings.providers.traderepublic_panel.reverse_engineered", default: "This integration is based on reverse-engineered API endpoints and may break without notice. Use at your own risk.") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% items = Current.family.traderepublic_items.where.not(phone_number: nil).order(created_at: :desc) %>
|
||||
|
||||
<% if items.any? %>
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium text-primary"><%= t("settings.providers.traderepublic_panel.connected_accounts", default: "Connected Accounts") %></h3>
|
||||
|
||||
<% items.each do |item| %>
|
||||
<div id="traderepublic-item-<%= item.id %>" class="p-4 border border-primary rounded-lg <%= item.status == "requires_update" ? "bg-warning/5 border-warning" : "" %>">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm text-primary"><%= item.name %></span>
|
||||
<% if item.pending_login? %>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-warning/10 text-warning border border-warning">
|
||||
<%= t("settings.providers.traderepublic_panel.pending_verification", default: "Pending Verification") %>
|
||||
</span>
|
||||
<% elsif item.status == "requires_update" %>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-error/10 text-error border border-error">
|
||||
<%= t("settings.providers.traderepublic_panel.login_required", default: "Login Required") %>
|
||||
</span>
|
||||
<% elsif item.session_configured? %>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-success/10 text-success border border-success">
|
||||
<%= t("settings.providers.traderepublic_panel.connected", default: "Connected") %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
<% if item.phone_number %>
|
||||
<%= item.phone_number.gsub(/\d(?=\d{4})/, '*') %> •
|
||||
<% end %>
|
||||
<%= item.traderepublic_accounts.count %> <%= t("settings.providers.traderepublic_panel.accounts", default: "account(s)") %>
|
||||
<% if item.last_synced_at %>
|
||||
• <%= t("settings.providers.traderepublic_panel.last_synced", default: "Last synced") %> <%= time_ago_in_words(item.last_synced_at) %> <%= t("settings.providers.traderepublic_panel.ago", default: "ago") %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<% if item.pending_login? %>
|
||||
<%= link_to verify_pin_traderepublic_item_path(item),
|
||||
class: "inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon "shield-check", class: "w-3 h-3" %>
|
||||
<span><%= t("settings.providers.traderepublic_panel.verify_pin", default: "Verify PIN") %></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= button_to manual_sync_traderepublic_item_path(item),
|
||||
method: :post,
|
||||
class: "inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon "refresh-cw", class: "w-3 h-3" %>
|
||||
<span><%= t("settings.providers.traderepublic_panel.manual_sync", default: "Manual sync") %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to traderepublic_item_path(item),
|
||||
method: :delete,
|
||||
form: {
|
||||
data: {
|
||||
turbo_confirm: t("settings.providers.traderepublic_panel.disconnect_confirm", default: "Are you sure you want to disconnect this Trade Republic account?") ,
|
||||
turbo_frame: "_top"
|
||||
}
|
||||
},
|
||||
class: "inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg text-error button-bg-secondary hover:button-bg-secondary-hover" do %>
|
||||
<%= icon "trash-2", class: "w-3 h-3" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="p-4 bg-subtle border border-primary rounded-lg">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t("settings.providers.traderepublic_panel.no_accounts", default: "No Trade Republic accounts connected yet. Use the 'Connect Account' button when adding a new account.") %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="text-xs text-secondary">
|
||||
<p><strong><%= t("settings.providers.traderepublic_panel.need_help", default: "Need help?") %></strong> <%= t("settings.providers.traderepublic_panel.guides_html", default: "Visit the <a href='%{url}' class='link'>guides</a> for detailed setup instructions.", url: settings_guides_path).html_safe %></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,6 +31,12 @@
|
||||
<%# They require custom UI for connection management, status display, and sync actions. %>
|
||||
<%# The controller excludes them from @provider_configurations (see prepare_show_context). %>
|
||||
|
||||
<%= settings_section title: "Trade Republic", collapsible: true, open: false do %>
|
||||
<turbo-frame id="traderepublic-providers-panel">
|
||||
<%= render "settings/providers/traderepublic_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Lunch Flow", collapsible: true, open: false do %>
|
||||
<turbo-frame id="lunchflow-providers-panel">
|
||||
<%= render "settings/providers/lunchflow_panel" %>
|
||||
|
||||
15
app/views/shared/_flash.html.erb
Normal file
15
app/views/shared/_flash.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<% if flash.any? %>
|
||||
<div id="flash-messages" class="space-y-2">
|
||||
<% flash.each do |type, message| %>
|
||||
<%# Map Rails flash types to Tailwind/Design System classes %>
|
||||
<% css_class = case type.to_sym
|
||||
when :notice then 'bg-success text-on-success'
|
||||
when :alert then 'bg-error text-on-error'
|
||||
else 'bg-secondary text-on-secondary'
|
||||
end %>
|
||||
<div class="rounded px-4 py-2 text-sm font-medium <%= css_class %>">
|
||||
<%= message %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
22
app/views/traderepublic_items/_api_error.html.erb
Normal file
22
app/views/traderepublic_items/_api_error.html.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title", default: "Connection Error")) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-error/10 border border-error rounded-lg">
|
||||
<p class="text-sm text-error">
|
||||
<%= icon "alert-circle", class: "inline-block w-5 h-5 mr-2" %>
|
||||
<%= local_assigns[:error_message] || t(".generic_error", default: "An error occurred while connecting to Trade Republic.") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".back", default: "Go Back"), local_assigns[:return_path] || new_account_path,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover",
|
||||
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
135
app/views/traderepublic_items/_verify_pin.html.erb
Normal file
135
app/views/traderepublic_items/_verify_pin.html.erb
Normal file
@@ -0,0 +1,135 @@
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title", default: "Verify Device PIN")) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-success/10 border border-success rounded-lg">
|
||||
<p class="text-sm text-success">
|
||||
<%= icon "check-circle", class: "inline-block w-5 h-5 mr-2" %>
|
||||
<%= t(".code_sent", default: "Verification code sent to your Trade Republic app!") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-accent/10 border border-accent rounded-lg">
|
||||
<p class="text-sm text-primary">
|
||||
<%= icon "smartphone", class: "inline-block w-5 h-5 mr-2" %>
|
||||
<%= t(".instruction", default: "Please enter the 4-digit code from your Trade Republic app below.") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="pin-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="device_pin" class="block text-sm font-medium text-primary mb-2">
|
||||
<%= t(".pin_label", default: "Verification Code") %>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="device_pin"
|
||||
name="device_pin"
|
||||
placeholder="<%= t(".pin_placeholder", default: "Enter 4-digit code") %>"
|
||||
maxlength="4"
|
||||
pattern="[0-9]{4}"
|
||||
autocomplete="off"
|
||||
class="w-full px-4 py-3 border border-primary rounded-lg text-center text-2xl font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<p id="error-message" class="text-xs text-error mt-2 hidden"></p>
|
||||
</div>
|
||||
|
||||
<div id="loading-spinner" class="hidden flex items-center justify-center gap-2 text-sm text-secondary">
|
||||
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
|
||||
<span><%= t(".verifying", default: "Verifying...") %></span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".cancel", default: "Cancel"), traderepublic_items_path,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
|
||||
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||
<button
|
||||
type="button"
|
||||
id="verify-btn"
|
||||
class="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled"
|
||||
>
|
||||
<%= t(".verify", default: "Verify") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const pinInput = document.getElementById('device_pin');
|
||||
const verifyBtn = document.getElementById('verify-btn');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const loadingSpinner = document.getElementById('loading-spinner');
|
||||
const pinForm = document.getElementById('pin-form');
|
||||
|
||||
// Auto-focus on input
|
||||
pinInput?.focus();
|
||||
|
||||
// Only allow numbers
|
||||
pinInput?.addEventListener('input', (e) => {
|
||||
e.target.value = e.target.value.replace(/[^0-9]/g, '');
|
||||
errorMessage.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Submit on Enter
|
||||
pinInput?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && pinInput.value.length === 4) {
|
||||
verifyBtn?.click();
|
||||
}
|
||||
});
|
||||
|
||||
verifyBtn?.addEventListener('click', async () => {
|
||||
const devicePin = pinInput.value.trim();
|
||||
|
||||
if (devicePin.length !== 4) {
|
||||
errorMessage.textContent = '<%= t(".pin_invalid", default: "Please enter a 4-digit code") %>';
|
||||
errorMessage.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading
|
||||
verifyBtn.disabled = true;
|
||||
loadingSpinner.classList.remove('hidden');
|
||||
errorMessage.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('<%= complete_login_traderepublic_item_path(traderepublic_item) %>', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
device_pin: devicePin,
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// Success - redirect to next step
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
} else {
|
||||
// Default redirect to traderepublic items index
|
||||
window.location.href = '<%= traderepublic_items_path %>';
|
||||
}
|
||||
} else {
|
||||
// Error
|
||||
errorMessage.textContent = data.error || '<%= t(".verification_failed", default: "Verification failed. Please try again.") %>';
|
||||
errorMessage.classList.remove('hidden');
|
||||
verifyBtn.disabled = false;
|
||||
loadingSpinner.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Verification error:', error);
|
||||
errorMessage.textContent = '<%= t(".network_error", default: "Network error. Please try again.") %>';
|
||||
errorMessage.classList.remove('hidden');
|
||||
verifyBtn.disabled = false;
|
||||
loadingSpinner.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
38
app/views/traderepublic_items/edit.html.erb
Normal file
38
app/views/traderepublic_items/edit.html.erb
Normal file
@@ -0,0 +1,38 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title", default: "Edit Trade Republic Connection")) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<%= form_with model: @traderepublic_item, url: traderepublic_item_path(@traderepublic_item), method: :patch, class: "space-y-4" do |f| %>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<%= f.label :name, t(".name_label", default: "Connection Name"), class: "block text-sm font-medium text-primary mb-1" %>
|
||||
<%= f.text_field :name,
|
||||
placeholder: t(".name_placeholder", default: "Trade Republic"),
|
||||
class: "w-full px-3 py-2 border border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent" %>
|
||||
<% if @traderepublic_item.errors[:name].any? %>
|
||||
<p class="text-xs text-error mt-1"><%= @traderepublic_item.errors[:name].first %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-info/10 border border-info rounded-lg">
|
||||
<p class="text-xs text-info">
|
||||
<%= icon "info", class: "inline-block w-4 h-4 mr-1" %>
|
||||
<%= t(".info_notice", default: "To update credentials, please delete this connection and create a new one.") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".cancel", default: "Cancel"), traderepublic_items_path,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
|
||||
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||
<%= f.submit t(".save", default: "Save Changes"),
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
121
app/views/traderepublic_items/index.html.erb
Normal file
121
app/views/traderepublic_items/index.html.erb
Normal file
@@ -0,0 +1,121 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-primary"><%= t(".title", default: "Trade Republic Connections") %></h2>
|
||||
<p class="text-secondary mt-1"><%= t(".description", default: "Manage your Trade Republic broker connections") %></p>
|
||||
</div>
|
||||
<%= link_to new_traderepublic_item_path,
|
||||
class: "btn btn-primary",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon "plus", class: "w-4 h-4 mr-2" %>
|
||||
<%= t(".new_connection", default: "New Connection") %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @traderepublic_items.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @traderepublic_items.each do |item| %>
|
||||
<div class="bg-container border border-secondary rounded-lg p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-medium text-primary"><%= item.name %></h3>
|
||||
<% if item.pending_login? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<%= icon "clock", class: "w-3 h-3 mr-1" %>
|
||||
Pending Verification
|
||||
</span>
|
||||
<% elsif item.status_good? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<%= icon "check-circle", class: "w-3 h-3 mr-1" %>
|
||||
Connected
|
||||
</span>
|
||||
<% elsif item.status_requires_update? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
<%= icon "alert-circle", class: "w-3 h-3 mr-1" %>
|
||||
Requires Update
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1 text-sm text-secondary">
|
||||
<p>
|
||||
<%= icon "phone", class: "w-4 h-4 inline mr-1" %>
|
||||
<%= item.phone_number %>
|
||||
</p>
|
||||
<% if item.last_synced_at %>
|
||||
<p>
|
||||
<%= icon "refresh-cw", class: "w-4 h-4 inline mr-1" %>
|
||||
Last synced <%= time_ago_in_words(item.last_synced_at) %> ago
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if item.traderepublic_accounts.any? %>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm font-medium text-primary mb-2">Linked Accounts:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% item.traderepublic_accounts.includes(:account).each do |tr_account| %>
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-md bg-gray-100 text-sm">
|
||||
<%= icon "briefcase", class: "w-3 h-3 mr-1" %>
|
||||
<%= tr_account.account&.name || tr_account.name %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<% if item.pending_login? %>
|
||||
<%= link_to verify_pin_traderepublic_item_path(item),
|
||||
class: "btn btn-sm btn-primary",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon "key", class: "w-4 h-4 mr-1" %>
|
||||
Verify PIN
|
||||
<% end %>
|
||||
<% elsif item.status_good? %>
|
||||
<%= button_to sync_traderepublic_item_path(item),
|
||||
method: :post,
|
||||
class: "btn btn-sm btn-secondary",
|
||||
form: { data: { turbo: false } } do %>
|
||||
<%= icon "refresh-cw", class: "w-4 h-4 mr-1" %>
|
||||
Sync Now
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= link_to edit_traderepublic_item_path(item),
|
||||
class: "btn btn-sm btn-secondary",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon "edit-2", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to traderepublic_item_path(item),
|
||||
method: :delete,
|
||||
class: "btn btn-sm btn-secondary text-red-600 hover:text-red-700",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to delete this connection?" } } do %>
|
||||
<%= icon "trash-2", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12 bg-container border border-secondary rounded-lg">
|
||||
<%= icon "briefcase", class: "w-12 h-12 mx-auto text-gray-400 mb-4" %>
|
||||
<h3 class="text-lg font-medium text-primary mb-2">
|
||||
<%= t(".no_connections", default: "No Trade Republic connections") %>
|
||||
</h3>
|
||||
<p class="text-secondary mb-6">
|
||||
<%= t(".no_connections_description", default: "Connect your Trade Republic account to sync your portfolio and transactions") %>
|
||||
</p>
|
||||
<%= link_to new_traderepublic_item_path,
|
||||
class: "btn btn-primary",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon "plus", class: "w-4 h-4 mr-2" %>
|
||||
<%= t(".add_first_connection", default: "Add Your First Connection") %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
97
app/views/traderepublic_items/new.html.erb
Normal file
97
app/views/traderepublic_items/new.html.erb
Normal file
@@ -0,0 +1,97 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title", default: "Connect Trade Republic")) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".description", default: "Enter your Trade Republic phone number and PIN to connect your account.") %>
|
||||
</p>
|
||||
|
||||
<%= form_with model: @traderepublic_item, url: traderepublic_items_path, method: :post, class: "space-y-4" do |f| %>
|
||||
<%= hidden_field_tag :accountable_type, @accountable_type %>
|
||||
<%= hidden_field_tag :return_to, @return_to %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<%= f.label :name, t(".name_label", default: "Connection Name"), class: "block text-sm font-medium text-primary mb-1" %>
|
||||
<%= f.text_field :name,
|
||||
value: "Trade Republic",
|
||||
placeholder: t(".name_placeholder", default: "Trade Republic"),
|
||||
class: "w-full px-3 py-2 border border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent" %>
|
||||
<% if @traderepublic_item.errors[:name].any? %>
|
||||
<p class="text-xs text-error mt-1"><%= @traderepublic_item.errors[:name].first %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :phone_number, t(".phone_label", default: "Phone Number"), class: "block text-sm font-medium text-primary mb-1" %>
|
||||
<%= f.text_field :phone_number,
|
||||
placeholder: t(".phone_placeholder", default: "+491234567890"),
|
||||
class: "w-full px-3 py-2 border border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent",
|
||||
autocomplete: "tel" %>
|
||||
<% if @traderepublic_item.errors[:phone_number].any? %>
|
||||
<p class="text-xs text-error mt-1"><%= @traderepublic_item.errors[:phone_number].first %></p>
|
||||
<% end %>
|
||||
<p class="text-xs text-secondary mt-1">
|
||||
<%= t(".phone_help", default: "International format with country code (e.g., +491234567890 for Germany)") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :pin, t(".pin_label", default: "PIN"), class: "block text-sm font-medium text-primary mb-1" %>
|
||||
<%= f.password_field :pin,
|
||||
placeholder: t(".pin_placeholder", default: "••••"),
|
||||
class: "w-full px-3 py-2 border border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent",
|
||||
autocomplete: "off" %>
|
||||
<% if @traderepublic_item.errors[:pin].any? %>
|
||||
<p class="text-xs text-error mt-1"><%= @traderepublic_item.errors[:pin].first %></p>
|
||||
<% end %>
|
||||
<p class="text-xs text-secondary mt-1">
|
||||
<%= t(".pin_help", default: "Your 4-digit Trade Republic PIN") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-info/10 border border-info rounded-lg">
|
||||
<p class="text-xs text-info">
|
||||
<%= icon "info", class: "inline-block w-4 h-4 mr-1" %>
|
||||
<%= t(".security_notice", default: "Your credentials are encrypted and stored securely. After connecting, you will receive a verification code on your phone.") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading-spinner" class="hidden flex items-center justify-center gap-2 text-sm text-secondary py-2">
|
||||
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
|
||||
<span><%= t(".sending_code", default: "Sending verification code...") %></span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".cancel", default: "Cancel"), @return_to || new_account_path,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
|
||||
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||
<%= f.submit t(".connect", default: "Connect"),
|
||||
id: "connect-btn",
|
||||
data: { disable_with: false },
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const form = document.querySelector('form');
|
||||
const loadingSpinner = document.getElementById('loading-spinner');
|
||||
const connectBtn = document.getElementById('connect-btn');
|
||||
|
||||
form?.addEventListener('submit', (e) => {
|
||||
// Show loading spinner
|
||||
loadingSpinner?.classList.remove('hidden');
|
||||
// Disable button
|
||||
connectBtn?.setAttribute('disabled', 'disabled');
|
||||
connectBtn?.classList.add('button-bg-disabled');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<% end %>
|
||||
56
app/views/traderepublic_items/select_accounts.html.erb
Normal file
56
app/views/traderepublic_items/select_accounts.html.erb
Normal file
@@ -0,0 +1,56 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title", default: "Select Trade Republic Accounts")) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".description", default: "Select the accounts you want to link from your Trade Republic portfolio.") %>
|
||||
</p>
|
||||
|
||||
<form action="<%= link_accounts_traderepublic_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
|
||||
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
|
||||
<%= hidden_field_tag :accountable_type, @accountable_type %>
|
||||
<%= hidden_field_tag :return_to, @return_to %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% @available_accounts.each do |account| %>
|
||||
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-subtle cursor-pointer transition-colors">
|
||||
<%= check_box_tag "account_ids[]", account.id, false, class: "mt-1" %>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm text-primary">
|
||||
<%= account.name %>
|
||||
</div>
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
<% if account.current_balance %>
|
||||
<%= number_to_currency(account.current_balance, unit: account.currency, precision: 2) %> •
|
||||
<% end %>
|
||||
<%= account.currency %> • <%= account.account_type&.humanize || "Investment" %>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @available_accounts.empty? %>
|
||||
<div class="p-4 bg-warning/10 border border-warning rounded-lg">
|
||||
<p class="text-sm text-warning">
|
||||
<%= icon "alert-triangle", class: "inline-block w-4 h-4 mr-1" %>
|
||||
<%= t(".no_accounts", default: "No accounts available for linking. All accounts may already be linked.") %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".cancel", default: "Cancel"), @return_to || new_account_path,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
|
||||
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||
<%= submit_tag t(".link_accounts", default: "Link Selected Accounts"),
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled",
|
||||
disabled: @available_accounts.empty? %>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,55 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title", default: "Link Trade Republic Account")) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".description", default: "Select a Trade Republic account to link with %{account_name}.", account_name: @account.name) %>
|
||||
</p>
|
||||
|
||||
<form action="<%= link_existing_account_traderepublic_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
|
||||
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
|
||||
<%= hidden_field_tag :account_id, @account.id %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% @available_accounts.each do |tr_account| %>
|
||||
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-subtle cursor-pointer transition-colors">
|
||||
<%= radio_button_tag "traderepublic_account_id", tr_account.id, false, class: "mt-1" %>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm text-primary">
|
||||
<%= tr_account.name %>
|
||||
</div>
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
<% if tr_account.current_balance %>
|
||||
<%= number_to_currency(tr_account.current_balance, unit: tr_account.currency, precision: 2) %> •
|
||||
<% end %>
|
||||
<%= tr_account.currency %> • <%= tr_account.account_type&.humanize || "Investment" %>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @available_accounts.empty? %>
|
||||
<div class="p-4 bg-warning/10 border border-warning rounded-lg">
|
||||
<p class="text-sm text-warning">
|
||||
<%= icon "alert-triangle", class: "inline-block w-4 h-4 mr-1" %>
|
||||
<%= t(".no_accounts", default: "No Trade Republic accounts available. Please connect a new Trade Republic account first.") %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".cancel", default: "Cancel"), account_path(@account),
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
|
||||
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||
<%= submit_tag t(".link_account", default: "Link Account"),
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled",
|
||||
disabled: @available_accounts.empty? %>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
130
app/views/traderepublic_items/verify_pin.html.erb
Normal file
130
app/views/traderepublic_items/verify_pin.html.erb
Normal file
@@ -0,0 +1,130 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title", default: "Verify Device PIN")) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-accent/10 border border-accent rounded-lg">
|
||||
<p class="text-sm text-primary">
|
||||
<%= icon "smartphone", class: "inline-block w-5 h-5 mr-2" %>
|
||||
<%= t(".instruction", default: "A verification code has been sent to your Trade Republic app. Please enter the code below.") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="pin-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="device_pin" class="block text-sm font-medium text-primary mb-2">
|
||||
<%= t(".pin_label", default: "Verification Code") %>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="device_pin"
|
||||
name="device_pin"
|
||||
placeholder="<%= t(".pin_placeholder", default: "Enter 4-digit code") %>"
|
||||
maxlength="4"
|
||||
pattern="[0-9]{4}"
|
||||
autocomplete="off"
|
||||
class="w-full px-4 py-3 border border-primary rounded-lg text-center text-2xl font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<p id="error-message" class="text-xs text-error mt-2 hidden"></p>
|
||||
</div>
|
||||
|
||||
<div id="loading-spinner" class="hidden flex items-center justify-center gap-2 text-sm text-secondary">
|
||||
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
|
||||
<span><%= t(".verifying", default: "Verifying...") %></span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".cancel", default: "Cancel"), traderepublic_items_path,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
|
||||
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||
<button
|
||||
type="button"
|
||||
id="verify-btn"
|
||||
class="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled"
|
||||
>
|
||||
<%= t(".verify", default: "Verify") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const pinInput = document.getElementById('device_pin');
|
||||
const verifyBtn = document.getElementById('verify-btn');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const loadingSpinner = document.getElementById('loading-spinner');
|
||||
const pinForm = document.getElementById('pin-form');
|
||||
|
||||
// Auto-focus on input
|
||||
pinInput?.focus();
|
||||
|
||||
// Only allow numbers
|
||||
pinInput?.addEventListener('input', (e) => {
|
||||
e.target.value = e.target.value.replace(/[^0-9]/g, '');
|
||||
errorMessage.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Submit on Enter
|
||||
pinInput?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && pinInput.value.length === 4) {
|
||||
verifyBtn?.click();
|
||||
}
|
||||
});
|
||||
|
||||
verifyBtn?.addEventListener('click', async () => {
|
||||
const devicePin = pinInput.value.trim();
|
||||
|
||||
if (devicePin.length !== 4) {
|
||||
errorMessage.textContent = '<%= t(".pin_invalid", default: "Please enter a 4-digit code") %>';
|
||||
errorMessage.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading
|
||||
verifyBtn.disabled = true;
|
||||
loadingSpinner.classList.remove('hidden');
|
||||
errorMessage.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('<%= complete_login_traderepublic_item_path(@traderepublic_item) %><%= "?manual_sync=1" if local_assigns[:manual_sync] || params[:manual_sync] %>', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
device_pin: devicePin,
|
||||
accountable_type: '<%= params[:accountable_type] || "Investment" %>',
|
||||
return_to: '<%= @return_to %>',
|
||||
manual_sync: <%= (local_assigns[:manual_sync] || params[:manual_sync]) ? 'true' : 'false' %>
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Redirect to account selection
|
||||
window.location.href = data.redirect_url;
|
||||
} else {
|
||||
errorMessage.textContent = data.error || '<%= t(".verification_failed", default: "Verification failed. Please try again.") %>';
|
||||
errorMessage.classList.remove('hidden');
|
||||
verifyBtn.disabled = false;
|
||||
loadingSpinner.classList.add('hidden');
|
||||
pinInput.value = '';
|
||||
pinInput.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Verification error:', error);
|
||||
errorMessage.textContent = '<%= t(".network_error", default: "Network error. Please try again.") %>';
|
||||
errorMessage.classList.remove('hidden');
|
||||
verifyBtn.disabled = false;
|
||||
loadingSpinner.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<% end %>
|
||||
92
config/locales/traderepublic.en.yml
Normal file
92
config/locales/traderepublic.en.yml
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
en:
|
||||
settings:
|
||||
providers:
|
||||
traderepublic_panel:
|
||||
important: "Important:"
|
||||
intro: "Trade Republic integration uses an unofficial API and requires 2-factor authentication."
|
||||
need_phone_pin: "You'll need your phone number and Trade Republic PIN"
|
||||
verification_code: "A verification code will be sent to your Trade Republic app during setup"
|
||||
sessions_stored: "Sessions are stored securely and will need to be refreshed periodically"
|
||||
note: "Note:"
|
||||
reverse_engineered: "This integration is based on reverse-engineered API endpoints and may break without notice. Use at your own risk."
|
||||
connected_accounts: "Connected Accounts"
|
||||
pending_verification: "Pending Verification"
|
||||
login_required: "Login Required"
|
||||
connected: "Connected"
|
||||
accounts: "account(s)"
|
||||
last_synced: "Last synced"
|
||||
ago: "ago"
|
||||
verify_pin: "Verify PIN"
|
||||
manual_sync: "Manual sync"
|
||||
disconnect_confirm: "Are you sure you want to disconnect this Trade Republic account?"
|
||||
no_accounts: "No Trade Republic accounts connected yet. Use the 'Connect Account' button when adding a new account."
|
||||
need_help: "Need help?"
|
||||
guides_html: "Visit the <a href='%{url}' class='link'>guides</a> for detailed setup instructions."
|
||||
traderepublic:
|
||||
items:
|
||||
errors:
|
||||
pin_required: "PIN is required"
|
||||
provider_not_configured: "TradeRepublic provider is not configured"
|
||||
no_process_id: "No processId found"
|
||||
provider:
|
||||
name: "Trade Republic"
|
||||
description: "Connect to your Trade Republic account"
|
||||
new:
|
||||
title: "Connect Trade Republic"
|
||||
description: "Enter your Trade Republic phone number and PIN to connect your account."
|
||||
name_label: "Connection Name"
|
||||
name_placeholder: "Trade Republic"
|
||||
phone_label: "Phone Number"
|
||||
phone_placeholder: "+491234567890"
|
||||
phone_help: "International format with country code (e.g., +491234567890 for Germany)"
|
||||
select_accounts:
|
||||
title: "Select Trade Republic Accounts"
|
||||
description: "Select the accounts you want to link from your Trade Republic portfolio."
|
||||
no_accounts: "No accounts available for linking. All accounts may already be linked."
|
||||
select_existing_account:
|
||||
title: "Link Trade Republic Account"
|
||||
description: "Select a Trade Republic account to link with %{account_name}."
|
||||
no_accounts: "No Trade Republic accounts available. Please connect a new Trade Republic account first."
|
||||
api_error:
|
||||
title: "Connection Error"
|
||||
generic_error: "An error occurred while connecting to Trade Republic."
|
||||
back: "Go Back"
|
||||
verify_pin:
|
||||
title: "Verify Device PIN"
|
||||
code_sent: "Verification code sent to your Trade Republic app!"
|
||||
instruction: "Please enter the 4-digit code from your Trade Republic app below."
|
||||
pin_label: "Verification Code"
|
||||
pin_placeholder: "Enter 4-digit code"
|
||||
verifying: "Verifying..."
|
||||
cancel: "Cancel"
|
||||
manual_sync:
|
||||
device_pin_sent: "Please check your phone for the verification PIN"
|
||||
login_failed: "Manual sync failed: %{message}"
|
||||
complete_login:
|
||||
pin_required: "PIN is required"
|
||||
sync_failed: "Connection successful but failed to fetch accounts. Please try syncing manually."
|
||||
verification_failed: "PIN verification failed"
|
||||
unexpected_error: "An unexpected error occurred"
|
||||
no_active_connection: "No active Trade Republic connection found"
|
||||
error_loading_accounts: "Failed to load accounts"
|
||||
no_accounts_available: "No Trade Republic accounts available for linking"
|
||||
link_accounts:
|
||||
no_accounts_selected: "No accounts selected"
|
||||
no_connection: "No Trade Republic connection found"
|
||||
accounts_linked: "Successfully linked %{count} Trade Republic account(s)"
|
||||
accounts_already_linked: "Selected accounts are already linked"
|
||||
no_valid_accounts: "No valid accounts to link"
|
||||
update:
|
||||
updated: "Trade Republic connection updated successfully"
|
||||
destroy:
|
||||
scheduled_for_deletion: "Trade Republic connection scheduled for deletion"
|
||||
sync:
|
||||
sync_started: "Sync started"
|
||||
reauthenticate:
|
||||
device_pin_sent: "Please check your phone for the verification PIN"
|
||||
login_failed: "Re-authentication failed: %{message}"
|
||||
link_existing_account:
|
||||
no_account_selected: "No Trade Republic account selected"
|
||||
already_linked: "This Trade Republic account is already linked"
|
||||
linked_successfully: "Trade Republic account linked successfully"
|
||||
@@ -510,6 +510,23 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :traderepublic_items, only: %i[index new create show edit update destroy] do
|
||||
collection do
|
||||
get :select_accounts
|
||||
post :link_accounts
|
||||
get :select_existing_account
|
||||
post :link_existing_account
|
||||
end
|
||||
|
||||
member do
|
||||
post :sync
|
||||
post :reauthenticate
|
||||
post :manual_sync
|
||||
get :verify_pin
|
||||
post :complete_login
|
||||
end
|
||||
end
|
||||
|
||||
namespace :webhooks do
|
||||
post "plaid"
|
||||
post "plaid_eu"
|
||||
|
||||
19
db/migrate/20260102120000_create_traderepublic_items.rb
Normal file
19
db/migrate/20260102120000_create_traderepublic_items.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class CreateTraderepublicItems < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :traderepublic_items, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.string :name, null: false
|
||||
t.string :phone_number, null: false
|
||||
t.string :pin, null: false
|
||||
t.string :status, null: false, default: "good"
|
||||
t.boolean :scheduled_for_deletion, null: false, default: false
|
||||
t.boolean :pending_account_setup, null: false, default: false
|
||||
t.datetime :sync_start_date, null: false
|
||||
|
||||
t.index :status
|
||||
t.jsonb :raw_payload, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
20
db/migrate/20260102120100_create_traderepublic_accounts.rb
Normal file
20
db/migrate/20260102120100_create_traderepublic_accounts.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
class CreateTraderepublicAccounts < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :traderepublic_accounts, id: :uuid do |t|
|
||||
t.references :traderepublic_item, null: false, foreign_key: true, type: :uuid
|
||||
t.string :account_id
|
||||
t.string :name
|
||||
t.string :currency
|
||||
t.decimal :current_balance, precision: 19, scale: 4
|
||||
t.string :account_status
|
||||
t.string :account_type
|
||||
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_transactions_payload
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :traderepublic_accounts, :account_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class AddProcessIdToTraderepublicItems < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :traderepublic_items, :process_id, :string
|
||||
add_column :traderepublic_items, :session_token, :string
|
||||
add_column :traderepublic_items, :refresh_token, :string
|
||||
add_column :traderepublic_items, :session_cookies, :jsonb
|
||||
end
|
||||
end
|
||||
42
db/schema.rb
generated
42
db/schema.rb
generated
@@ -1483,6 +1483,42 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do
|
||||
t.index ["message_id"], name: "index_tool_calls_on_message_id"
|
||||
end
|
||||
|
||||
create_table "traderepublic_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "traderepublic_item_id", null: false
|
||||
t.string "account_id"
|
||||
t.string "name"
|
||||
t.string "currency"
|
||||
t.decimal "current_balance", precision: 19, scale: 4
|
||||
t.string "account_status"
|
||||
t.string "account_type"
|
||||
t.jsonb "raw_payload"
|
||||
t.jsonb "raw_transactions_payload"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_traderepublic_accounts_on_account_id"
|
||||
t.index ["traderepublic_item_id"], name: "index_traderepublic_accounts_on_traderepublic_item_id"
|
||||
end
|
||||
|
||||
create_table "traderepublic_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "name"
|
||||
t.string "phone_number"
|
||||
t.string "pin"
|
||||
t.string "status", default: "good"
|
||||
t.boolean "scheduled_for_deletion", default: false
|
||||
t.boolean "pending_account_setup", default: false
|
||||
t.datetime "sync_start_date"
|
||||
t.jsonb "raw_payload"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "process_id"
|
||||
t.string "session_token"
|
||||
t.string "refresh_token"
|
||||
t.jsonb "session_cookies"
|
||||
t.index ["family_id"], name: "index_traderepublic_items_on_family_id"
|
||||
t.index ["status"], name: "index_traderepublic_items_on_status"
|
||||
end
|
||||
|
||||
create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "security_id", null: false
|
||||
t.decimal "qty", precision: 24, scale: 8
|
||||
@@ -1672,6 +1708,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do
|
||||
add_foreign_key "taggings", "tags"
|
||||
add_foreign_key "tags", "families"
|
||||
add_foreign_key "tool_calls", "messages"
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
add_foreign_key "traderepublic_accounts", "traderepublic_items"
|
||||
add_foreign_key "traderepublic_items", "families"
|
||||
add_foreign_key "trades", "categories"
|
||||
>>>>>>> Add TradeRepublic provider
|
||||
add_foreign_key "trades", "securities"
|
||||
add_foreign_key "transactions", "categories", on_delete: :nullify
|
||||
add_foreign_key "transactions", "merchants"
|
||||
|
||||
45
docs/providers/TRADEREPUBLIC_DESCRIPTION_EN.md
Normal file
45
docs/providers/TRADEREPUBLIC_DESCRIPTION_EN.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Trade Republic Provider – Description
|
||||
|
||||
## Overview
|
||||
|
||||
The Trade Republic provider enables automatic synchronization of Trade Republic accounts and transactions into the Sure application, using an unofficial WebSocket integration.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **2FA Authentication**: Secure login with phone number, PIN, and code received from the Trade Republic app.
|
||||
- **Session Management**: Encrypted storage of tokens and cookies, support for processId in the authentication flow.
|
||||
- **Account & Transaction Import**: Fetches portfolio, balances, and transaction history via WebSocket.
|
||||
- **Automated Sync**: Manual or Sidekiq-triggered sync, orchestrated by dedicated jobs and services.
|
||||
- **Modular Architecture**: Dedicated models, services, jobs, and controllers, following Rails and project conventions.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
- **Main Models**: `TraderepublicItem` (connection), `TraderepublicAccount` (account), `Provider::Traderepublic` (WebSocket client).
|
||||
- **Services**: Importer, Syncer, Processor for importing, syncing, and parsing data.
|
||||
- **Jobs**: `TraderepublicItem::SyncJob` for background synchronization.
|
||||
- **Security**: Credentials and tokens encrypted via ActiveRecord Encryption, strict handling of sensitive data.
|
||||
|
||||
## Limitations & Considerations
|
||||
|
||||
- Unofficial API: Subject to change, no automatic refresh token yet.
|
||||
- Incomplete transaction and holdings parser: To be improved as needed.
|
||||
- Blocking WebSocket: Uses EventMachine, may impact scalability.
|
||||
- Manual authentication possible: Token extraction via browser if API issues occur.
|
||||
|
||||
## Deployment & Usage
|
||||
|
||||
- Required gems and migrations must be installed.
|
||||
- ActiveRecord Encryption keys must be configured.
|
||||
- Connection and sync tests via UI or Rails console.
|
||||
- Monitoring via logs and Sidekiq.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Deployment Guide](docs/providers/TRADEREPUBLIC_DEPLOYMENT.md)
|
||||
- [Quick Start](docs/providers/TRADEREPUBLIC_QUICKSTART.md)
|
||||
- [Manual Authentication](docs/providers/TRADEREPUBLIC_MANUAL_AUTH.md)
|
||||
- [Technical Documentation](docs/providers/TRADEREPUBLIC.md)
|
||||
|
||||
---
|
||||
|
||||
Feel free to adapt or extend this according to your PR context or documentation target.
|
||||
31
test/models/trade_republic/security_resolver_test.rb
Normal file
31
test/models/trade_republic/security_resolver_test.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
require "test_helper"
|
||||
|
||||
class TradeRepublic::SecurityResolverTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
Holding.delete_all
|
||||
Security::Price.delete_all
|
||||
Trade.delete_all
|
||||
Security.delete_all
|
||||
end
|
||||
|
||||
test "returns existing security by ISIN in name" do
|
||||
security = Security.create!(name: "Apple Inc. US0378331005", ticker: "AAPL1", exchange_operating_mic: "XNAS")
|
||||
resolver = TradeRepublic::SecurityResolver.new("US0378331005")
|
||||
assert_equal security, resolver.resolve
|
||||
end
|
||||
|
||||
test "creates new security if not found" do
|
||||
resolver = TradeRepublic::SecurityResolver.new("US0000000001", name: "Test Security", ticker: "TEST1", mic: "XTST")
|
||||
security = resolver.resolve
|
||||
assert security.persisted?
|
||||
assert_equal "Test Security (US0000000001)", security.name
|
||||
assert_equal "TEST1", security.ticker
|
||||
assert_equal "XTST", security.exchange_operating_mic
|
||||
end
|
||||
|
||||
test "returns existing security if ticker/mic already taken" do
|
||||
existing = Security.create!(name: "Existing", ticker: "DUPL1", exchange_operating_mic: "XDUP")
|
||||
resolver = TradeRepublic::SecurityResolver.new("US0000000002", name: "Other", ticker: "DUPL1", mic: "XDUP")
|
||||
assert_equal existing, resolver.resolve
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user