Files
sure/app/controllers/kraken_items_controller.rb
ghost be598aecf0 feat(providers): add Kraken exchange sync (#1759)
* feat(providers): add Kraken exchange sync

Adds family-scoped Kraken API-key connections, read-only balance and trade import, account setup/linking flows, provider status wiring, and focused test coverage.

Closes #1758

* test(providers): avoid Kraken sample secret false positive

* fix(providers): address Kraken review findings

* fix(providers): address Kraken review cleanup

* test(imports): stabilize transaction import ordering
2026-05-12 00:22:37 +02:00

242 lines
7.8 KiB
Ruby

# frozen_string_literal: true
class KrakenItemsController < ApplicationController
before_action :set_kraken_item, only: %i[update destroy sync setup_accounts complete_account_setup]
before_action :require_admin!, only: %i[create select_accounts link_accounts select_existing_account link_existing_account update destroy sync setup_accounts complete_account_setup]
def create
@kraken_item = Current.family.kraken_items.build(kraken_item_params)
@kraken_item.name ||= t(".default_name")
if @kraken_item.save
@kraken_item.set_kraken_institution_defaults!
@kraken_item.sync_later
render_panel_success(t(".success"))
else
render_panel_error(@kraken_item.errors.full_messages.join(", "))
end
end
def update
if @kraken_item.update(kraken_item_params)
render_panel_success(t(".success"))
else
render_panel_error(@kraken_item.errors.full_messages.join(", "))
end
end
def destroy
@kraken_item.unlink_all!(dry_run: false)
@kraken_item.destroy_later
redirect_to settings_providers_path, notice: t(".success")
end
def sync
@kraken_item.sync_later unless @kraken_item.syncing?
respond_to do |format|
format.html { redirect_back_or_to settings_providers_path }
format.json { head :ok }
end
end
def select_accounts
account_flow = kraken_item_account_flow_context
kraken_item = account_flow[:kraken_item]
unless kraken_item
redirect_to settings_providers_path, alert: kraken_item_selection_message(account_flow[:credentialed_items])
return
end
redirect_to setup_accounts_kraken_item_path(kraken_item, return_to: safe_return_to_path), status: :see_other
end
def link_accounts
kraken_item = kraken_item_account_flow_context[:kraken_item]
unless kraken_item
redirect_to settings_providers_path, alert: t(".select_connection")
return
end
redirect_to setup_accounts_kraken_item_path(kraken_item), status: :see_other
end
def select_existing_account
@account = Current.family.accounts.find(params[:account_id])
account_flow = kraken_item_account_flow_context
@kraken_item = account_flow[:kraken_item]
unless manual_crypto_exchange_account?(@account)
redirect_to accounts_path, alert: t("kraken_items.link_existing_account.errors.only_manual")
return
end
unless @kraken_item
redirect_to settings_providers_path, alert: kraken_item_selection_message(account_flow[:credentialed_items])
return
end
@available_kraken_accounts = @kraken_item.kraken_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.order(:name)
render :select_existing_account, layout: false
end
def link_existing_account
@account = Current.family.accounts.find(params[:account_id])
kraken_item = kraken_item_account_flow_context[:kraken_item]
unless manual_crypto_exchange_account?(@account)
return redirect_or_flash_error(t(".errors.only_manual"), account_path(@account))
end
unless kraken_item
redirect_to settings_providers_path, alert: t(".select_connection")
return
end
kraken_account = kraken_item.kraken_accounts.find_by(id: params[:kraken_account_id])
unless kraken_account
return redirect_or_flash_error(t(".errors.invalid_kraken_account"), account_path(@account))
end
if kraken_account.account_provider.present?
return redirect_or_flash_error(t(".errors.kraken_account_already_linked"), account_path(@account))
end
AccountProvider.create!(account: @account, provider: kraken_account)
kraken_item.sync_later
redirect_to accounts_path, notice: t(".success")
end
def setup_accounts
@kraken_accounts = unlinked_accounts_for(@kraken_item)
end
def complete_account_setup
selected_accounts = Array(params[:selected_accounts]).reject(&:blank?)
created_accounts = []
selected_accounts.each do |kraken_account_id|
kraken_account = @kraken_item.kraken_accounts.find_by(id: kraken_account_id)
next unless kraken_account
kraken_account.with_lock do
next if kraken_account.account_provider.present?
account = Account.create_from_kraken_account(kraken_account)
provider_link = kraken_account.ensure_account_provider!(account)
provider_link ? created_accounts << account : account.destroy!
end
KrakenAccount::Processor.new(kraken_account.reload).process
rescue StandardError => e
Rails.logger.error("Failed to setup account for KrakenAccount #{kraken_account_id}: #{e.message}")
end
@kraken_item.update!(pending_account_setup: unlinked_accounts_for(@kraken_item).exists?)
@kraken_item.sync_later if created_accounts.any?
notice = if created_accounts.any?
t(".success", count: created_accounts.count)
elsif selected_accounts.empty?
t(".none_selected")
else
t(".no_accounts")
end
redirect_to accounts_path, notice: notice, status: :see_other
end
private
def set_kraken_item
@kraken_item = Current.family.kraken_items.find(params[:id])
end
def kraken_item_params
permitted = params.require(:kraken_item).permit(:name, :sync_start_date, :api_key, :api_secret)
if @kraken_item&.persisted?
permitted.delete(:api_key) if permitted[:api_key].blank?
permitted.delete(:api_secret) if permitted[:api_secret].blank?
end
permitted
end
def render_panel_success(message)
if turbo_frame_request?
flash.now[:notice] = message
@kraken_items = Current.family.kraken_items.active.ordered
stream = turbo_stream.update("kraken-providers-panel", partial: "settings/providers/kraken_panel", locals: { kraken_items: @kraken_items })
render turbo_stream: [ stream, *flash_notification_stream_items ]
else
redirect_to settings_providers_path, notice: message, status: :see_other
end
end
def render_panel_error(message)
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"kraken-providers-panel",
partial: "settings/providers/kraken_panel",
locals: { error_message: message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: message, status: :see_other
end
end
def kraken_item_account_flow_context
credentialed_items = Current.family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?)
item = if params[:kraken_item_id].present?
credentialed_items.find { |candidate| candidate.id.to_s == params[:kraken_item_id].to_s }
elsif credentialed_items.one?
credentialed_items.first
end
{ kraken_item: item, credentialed_items: credentialed_items }
end
def unlinked_accounts_for(kraken_item)
kraken_item.kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).order(:name)
end
def kraken_item_selection_message(credentialed_items)
if credentialed_items.count > 1 && params[:kraken_item_id].blank?
t("kraken_items.select_accounts.select_connection")
else
t("kraken_items.select_accounts.no_credentials_configured")
end
end
def manual_crypto_exchange_account?(account)
account.manual_crypto_exchange?
end
def redirect_or_flash_error(message, fallback_path)
if turbo_frame_request?
flash.now[:alert] = message
render turbo_stream: Array(flash_notification_stream_items)
else
redirect_to fallback_path, alert: message
end
end
def safe_return_to_path
return nil if params[:return_to].blank?
value = params[:return_to].to_s
uri = URI.parse(value)
return nil if uri.scheme.present?
return nil if uri.host.present?
return nil unless value.start_with?("/")
value
rescue URI::InvalidURIError
nil
end
end