mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 21:14:56 +00:00
* 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
242 lines
7.8 KiB
Ruby
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
|