Files
sure/app/controllers/trading212_items_controller.rb
jdcdp 849578a84a Add support for trading212 integration in investments sync (#2513)
* Add support for trading212 integration in investments sync

* Respect Trading 212 history rate limits

* Replace hex colors with design tokens

* Changed withdrawal to withdraw

* Address PR review feedback for Trading 212 integration

- Replace all Rails.logger calls with DebugLogEntry.capture across
  syncer, importer, processor, provider, controller, and unlinking
- Fix destroy action to surface unlink errors via DebugLogEntry
- Replace hardcoded 'GBP' currency fallback with Current.family.currency
- Replace hardcoded English status strings in Syncer with i18n
- Add config/initializers/trading212.rb with DEBUG_RAW ENV toggle
- Add comprehensive test coverage: item, account, data_helpers,
  holdings_processor, activities_processor, importer, syncer,
  and controller tests
- Rewrite syncer and controller tests to match actual interfaces

* Fix tests and bugs found during local test execution

- Fix standard_ticker: empty string caused nil.upcase via [].first
- Fix parse_date: DateTime < Date, so when Date caught DateTime first
- Fix ActivitiesProcessor/HoldingsProcessor: missing instruments_map
  in DataHelpers caused NameError when processing dividends
- Fix test: security fixture ticker collision with test data
- Fix test: sync_status_summary i18n matching in assertions
- Fix test: trading212_provider ConfigurationError test path
- Fix test: controller invalid params needs Turbo-Frame header
- Fix test: syncer test uses stubs instead of strict expects

116 tests, 257 assertions, 0 failures, 0 errors

* Keep raw provider response bodies out of exception messages.

* Fix Secrets leak into page HTML, prevents the API key/secret from appearing in the HTML source while keeping the "leave blank to keep existing" UX.

* Address review findings: env gate, sync test, uniqueness test, destroy flow

- Gate TRADING212_DEBUG_RAW behind Rails.env.local? so staging/production
  cannot accidentally enable raw payload logging
- Assert SyncJob enqueue in sync controller test, not just redirect
- Fix cross-item uniqueness test to actually use two different items
- Remove rescue in destroy so unlink failures stop the flow (matching
  Brex/Akahu pattern) instead of proceeding to destroy_later silently

* Add Trading212 tables to db/schema.rb for CI test database

CI runs db:test:prepare which loads db/schema.rb, not migrations.
Without these table definitions, fixture loading fails with
PG::UndefinedTable: relation "trading212_accounts" does not exist.

* Removed lint complaint

* Register Trading212 in ProviderConnectionStatus::PROVIDERS

Fixes CI failure: test_provider_registry_covers_syncable_family_provider_item_associations

* Fixed bad merge

---------

Signed-off-by: jdcdp <47483528+jdcdp@users.noreply.github.com>
Co-authored-by: jdcdp <jdcdp@cdm4.net>
2026-07-25 04:54:30 +02:00

253 lines
8.7 KiB
Ruby

class Trading212ItemsController < ApplicationController
before_action :set_trading212_item, only: [ :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
before_action :require_admin!, only: [ :create, :select_accounts, :select_existing_account, :link_existing_account, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
def create
@trading212_item = Current.family.trading212_items.build(trading212_item_params)
@trading212_item.name ||= t("trading212_items.defaults.name")
@trading212_item.currency ||= Current.family.currency
if @trading212_item.save
@trading212_item.sync_later
if turbo_frame_request?
flash.now[:notice] = t(".success")
render turbo_stream: [
turbo_stream.replace(
"trading212-providers-panel",
partial: "settings/providers/trading212_panel"
),
*flash_notification_stream_items
]
else
redirect_to accounts_path, notice: t(".success"), status: :see_other
end
else
@error_message = @trading212_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"trading212-providers-panel",
partial: "settings/providers/trading212_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :see_other
end
end
end
def update
attrs = trading212_item_params.to_h
attrs["api_key"] = @trading212_item.api_key if attrs["api_key"].blank?
attrs["api_secret"] = @trading212_item.api_secret if attrs["api_secret"].blank?
if @trading212_item.update(attrs.merge(status: :good))
@trading212_item.sync_later unless @trading212_item.syncing?
if turbo_frame_request?
flash.now[:notice] = t(".success")
render turbo_stream: [
turbo_stream.replace(
"trading212-providers-panel",
partial: "settings/providers/trading212_panel"
),
*flash_notification_stream_items
]
else
redirect_to accounts_path, notice: t(".success"), status: :see_other
end
else
@error_message = @trading212_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"trading212-providers-panel",
partial: "settings/providers/trading212_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :see_other
end
end
end
def destroy
@trading212_item.unlink_all!(dry_run: false)
@trading212_item.destroy_later
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
def sync
@trading212_item.sync_later unless @trading212_item.syncing?
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
def select_accounts
item = current_trading212_item
unless item
redirect_to settings_providers_path, alert: t(".not_configured")
return
end
redirect_to setup_accounts_trading212_item_path(item)
end
def select_existing_account
@account = Current.family.accounts.find(params[:account_id])
@available_trading212_accounts = Current.family.trading212_items
.includes(trading212_accounts: { account_provider: :account })
.flat_map(&:trading212_accounts)
.select { |t212_account| t212_account.account_provider.nil? }
.sort_by { |t212_account| t212_account.updated_at || t212_account.created_at }
.reverse
render :select_existing_account, layout: false
end
def link_existing_account
account = Current.family.accounts.find_by(id: params[:account_id])
t212_account = Current.family.trading212_items
.joins(:trading212_accounts)
.where(trading212_accounts: { id: params[:trading212_account_id] })
.first
&.trading212_accounts
&.find_by(id: params[:trading212_account_id])
if account.blank? || t212_account.blank?
redirect_to settings_providers_path, alert: t(".not_found")
return
end
if account.accountable_type != "Investment" || account.account_providers.any? || account.plaid_account_id.present? || account.simplefin_account_id.present?
redirect_to account_path(account), alert: t(".only_manual_investment")
return
end
provider = nil
t212_account.with_lock do
if t212_account.current_account.present?
redirect_to account_path(account), alert: t(".already_linked")
return
end
provider = t212_account.ensure_account_provider!(account)
end
raise "Failed to create AccountProvider link" unless provider
begin
Trading212Account::Processor.new(t212_account.reload).process
rescue => e
DebugLogEntry.capture(
category: "sync",
level: "error",
message: "Failed to process linked Trading 212 account #{t212_account.id}: #{e.class} - #{e.message}",
source: "trading212",
family: Current.family,
provider_key: "trading212"
)
end
t212_account.trading212_item.sync_later unless t212_account.trading212_item.syncing?
redirect_to account_path(account), notice: t(".success"), status: :see_other
rescue => e
DebugLogEntry.capture(
category: "sync",
level: "error",
message: "Failed to link existing Trading 212 account: #{e.class} - #{e.message}",
source: "trading212",
family: Current.family,
provider_key: "trading212"
)
redirect_to settings_providers_path, alert: t(".failed"), status: :see_other
end
def setup_accounts
@trading212_accounts = @trading212_item.trading212_accounts.includes(account_provider: :account)
@linked_accounts = @trading212_accounts.select { |a| a.current_account.present? }
@unlinked_accounts = @trading212_accounts.reject { |a| a.current_account.present? }
no_accounts = @linked_accounts.blank? && @unlinked_accounts.blank?
latest_sync = @trading212_item.syncs.ordered.first
should_sync = latest_sync.nil? || !latest_sync.completed?
if no_accounts && !@trading212_item.syncing? && should_sync
@trading212_item.sync_later
end
@linkable_accounts = Current.family.accounts
.visible
.where(accountable_type: "Investment")
.left_joins(:account_providers)
.where(account_providers: { id: nil })
.order(:name)
@syncing = @trading212_item.syncing?
@waiting_for_sync = no_accounts && @syncing
@no_accounts_found = no_accounts && !@syncing && @trading212_item.last_synced_at.present?
end
def complete_account_setup
selected_accounts = Array(params[:account_ids]).reject(&:blank?)
created_accounts = []
selected_accounts.each do |t212_account_id|
t212_account = @trading212_item.trading212_accounts.find_by(id: t212_account_id)
next unless t212_account
t212_account.with_lock do
next if t212_account.current_account.present?
account = Account.create_from_trading212_account(t212_account)
t212_account.ensure_account_provider!(account)
created_accounts << account
end
begin
Trading212Account::Processor.new(t212_account.reload).process
rescue => e
DebugLogEntry.capture(
category: "sync",
level: "error",
message: "Failed to process Trading 212 account #{t212_account.id} after setup: #{e.class} - #{e.message}",
source: "trading212",
family: Current.family,
provider_key: "trading212"
)
end
end
@trading212_item.update!(pending_account_setup: @trading212_item.unlinked_accounts_count.positive?)
@trading212_item.sync_later if created_accounts.any?
if created_accounts.any?
redirect_to accounts_path, notice: t(".success", count: created_accounts.count), status: :see_other
elsif selected_accounts.empty?
redirect_to setup_accounts_trading212_item_path(@trading212_item), alert: t(".none_selected"), status: :see_other
else
redirect_to setup_accounts_trading212_item_path(@trading212_item), alert: t(".none_created"), status: :see_other
end
end
private
def set_trading212_item
@trading212_item = Current.family.trading212_items.find(params[:id])
end
def current_trading212_item
active_items = Current.family.trading212_items.active
active_items.syncable.ordered.first || active_items.ordered.first
end
def trading212_item_params
params.require(:trading212_item).permit(:api_key, :api_secret, :environment, :currency)
end
end