diff --git a/app/controllers/kraken_items_controller.rb b/app/controllers/kraken_items_controller.rb new file mode 100644 index 000000000..6daba850f --- /dev/null +++ b/app/controllers/kraken_items_controller.rb @@ -0,0 +1,241 @@ +# 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 diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 361097ae1..a3feda136 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -189,6 +189,7 @@ class Settings::ProvidersController < ApplicationController { key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" }, { key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" }, { key: "binance", title: "Binance", turbo_id: "binance", partial: "binance_panel" }, + { key: "kraken", title: "Kraken", turbo_id: "kraken", partial: "kraken_panel" }, { key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" }, { key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" }, { key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" } @@ -205,6 +206,7 @@ class Settings::ProvidersController < ApplicationController "mercury" => "MercuryItem", "coinbase" => "CoinbaseItem", "binance" => "BinanceItem", + "kraken" => "KrakenItem", "snaptrade" => "SnaptradeItem", "indexa_capital" => "IndexaCapitalItem", "sophtron" => "SophtronItem" @@ -226,6 +228,8 @@ class Settings::ProvidersController < ApplicationController @coinbase_items = Current.family.coinbase_items.ordered when "binance" @binance_items = Current.family.binance_items.active.ordered + when "kraken" + @kraken_items = Current.family.kraken_items.active.ordered when "snaptrade" @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered when "indexa_capital" @@ -255,6 +259,7 @@ class Settings::ProvidersController < ApplicationController @snaptrade_items = Current.family.snaptrade_items.ordered @indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id) @binance_items = Current.family.binance_items.active.ordered + @kraken_items = Current.family.kraken_items.active.ordered @provider_sync_health = compute_provider_sync_health(family_panel_items) @@ -279,6 +284,7 @@ class Settings::ProvidersController < ApplicationController "mercury" => @mercury_items, "coinbase" => @coinbase_items, "binance" => @binance_items, + "kraken" => @kraken_items, "snaptrade" => @snaptrade_items, "indexa_capital" => @indexa_capital_items, "sophtron" => @sophtron_items diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 3fc4f1af2..84aba4b58 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -92,6 +92,9 @@ module SettingsHelper when "binance" return { status: :off } unless @binance_items&.any? sync_based_summary(key) + when "kraken" + return { status: :off } unless @kraken_items&.any? + sync_based_summary(key) when "snaptrade" configured_item = @snaptrade_items&.find(&:credentials_configured?) return { status: :off } unless configured_item diff --git a/app/models/account.rb b/app/models/account.rb index 8a31a8d5e..0c50e1f70 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -266,6 +266,25 @@ class Account < ApplicationRecord create_and_sync(attributes, skip_initial_sync: true) end + def create_from_kraken_account(kraken_account) + family = kraken_account.kraken_item.family + + attributes = { + family: family, + name: kraken_account.name, + balance: (kraken_account.current_balance || 0).to_d, + cash_balance: 0, + currency: kraken_account.currency.presence || family.currency, + accountable_type: "Crypto", + accountable_attributes: { + subtype: "exchange", + tax_treatment: "taxable" + } + } + + create_and_sync(attributes, skip_initial_sync: true) + end + private @@ -298,6 +317,14 @@ class Account < ApplicationRecord read_attribute(:institution_domain).presence || provider&.institution_domain end + def manual_crypto_exchange? + accountable_type == "Crypto" && + accountable&.subtype == "exchange" && + account_providers.none? && + plaid_account_id.blank? && + simplefin_account_id.blank? + end + def logo_url if institution_domain.present? && Setting.brand_fetch_client_id.present? logo_size = Setting.brand_fetch_logo_size diff --git a/app/models/family.rb b/app/models/family.rb index 3a3547b58..fa7d1222d 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,7 +1,7 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable - include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, SophtronConnectable + include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, SophtronConnectable include IndexaCapitalConnectable DATE_FORMATS = [ diff --git a/app/models/family/kraken_connectable.rb b/app/models/family/kraken_connectable.rb new file mode 100644 index 000000000..6bc02d235 --- /dev/null +++ b/app/models/family/kraken_connectable.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Family::KrakenConnectable + extend ActiveSupport::Concern + + included do + has_many :kraken_items, dependent: :destroy + end + + def can_connect_kraken? + true + end + + def create_kraken_item!(api_key:, api_secret:, item_name: nil) + item = kraken_items.create!( + name: item_name || "Kraken", + api_key: api_key, + api_secret: api_secret + ) + + item.set_kraken_institution_defaults! + item.sync_later + item + end + + def has_kraken_credentials? + kraken_items.active.any?(&:credentials_configured?) + end +end diff --git a/app/models/kraken_account.rb b/app/models/kraken_account.rb new file mode 100644 index 000000000..e968f1e48 --- /dev/null +++ b/app/models/kraken_account.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class KrakenAccount < ApplicationRecord + include Encryptable + + STABLECOINS = %w[USDT USDC DAI PYUSD USDP TUSD USDG].freeze + FIAT_CURRENCIES = %w[USD EUR GBP CAD AUD CHF JPY AED].freeze + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + belongs_to :kraken_item + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + + validates :name, :account_id, :account_type, :currency, presence: true + + def current_account + account + end + + def ensure_account_provider!(target_account = nil) + acct = target_account || current_account + return nil unless acct + + AccountProvider + .find_or_initialize_by(provider_type: "KrakenAccount", provider_id: id) + .tap do |ap| + ap.account = acct + ap.save! + end + rescue StandardError => e + Rails.logger.warn("KrakenAccount #{id}: failed to link account provider - #{e.class}: #{e.message}") + nil + end +end diff --git a/app/models/kraken_account/asset_normalizer.rb b/app/models/kraken_account/asset_normalizer.rb new file mode 100644 index 000000000..7ad0a9e1c --- /dev/null +++ b/app/models/kraken_account/asset_normalizer.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class KrakenAccount::AssetNormalizer + SUFFIX_PATTERN = /(\.[A-Z])\z/ + FIAT_PREFIXES = { + "ZUSD" => "USD", + "ZEUR" => "EUR", + "ZGBP" => "GBP", + "ZCAD" => "CAD", + "ZAUD" => "AUD", + "ZCHF" => "CHF", + "ZJPY" => "JPY" + }.freeze + SYMBOL_FALLBACKS = { + "XBT" => "BTC", + "XXBT" => "BTC", + "XETH" => "ETH", + "ZUSD" => "USD" + }.freeze + + def initialize(asset_metadata = {}) + @asset_metadata = asset_metadata || {} + end + + def normalize(raw_asset) + raw = raw_asset.to_s.upcase + suffix = raw[SUFFIX_PATTERN, 1] + raw_base = suffix ? raw.delete_suffix(suffix) : raw + + metadata = metadata_for(raw, raw_base) + base_symbol = metadata_symbol(metadata, raw_base) + normalized_base = normalize_base_symbol(base_symbol) + symbol = suffix.present? ? "#{normalized_base}#{suffix}" : normalized_base + + { + raw_asset: raw, + raw_base: raw_base, + symbol: symbol, + price_symbol: normalized_base, + suffix: suffix, + metadata: metadata + } + end + + private + + attr_reader :asset_metadata + + def metadata_for(raw, raw_base) + asset_metadata[raw] || asset_metadata[raw_base] || asset_metadata.values.find do |metadata| + candidate = metadata_symbol(metadata, raw_base) + [ raw, raw_base ].include?(candidate.to_s.upcase) + end + end + + def metadata_symbol(metadata, fallback) + return fallback unless metadata.is_a?(Hash) + + metadata["altname"].presence || metadata["display_name"].presence || fallback + end + + def normalize_base_symbol(symbol) + value = symbol.to_s.upcase + value = FIAT_PREFIXES[value] if FIAT_PREFIXES.key?(value) + SYMBOL_FALLBACKS[value] || value + end +end diff --git a/app/models/kraken_account/holdings_processor.rb b/app/models/kraken_account/holdings_processor.rb new file mode 100644 index 000000000..d0a589ca4 --- /dev/null +++ b/app/models/kraken_account/holdings_processor.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class KrakenAccount::HoldingsProcessor + include KrakenAccount::UsdConverter + + def initialize(kraken_account) + @kraken_account = kraken_account + end + + def process + return unless account&.accountable_type == "Crypto" + + raw_assets.each { |asset| process_asset(asset) } + rescue StandardError => e + Rails.logger.error "KrakenAccount::HoldingsProcessor - error: #{e.message}" + nil + end + + private + + attr_reader :kraken_account + + def target_currency + kraken_account.kraken_item&.family&.currency + end + + def account + kraken_account.current_account + end + + def raw_assets + kraken_account.raw_payload&.dig("assets") || [] + end + + def process_asset(asset) + symbol = asset["symbol"] || asset[:symbol] + price_symbol = asset["price_symbol"] || asset[:price_symbol] || symbol + total = (asset["balance"] || asset[:balance] || 0).to_d + price_usd = asset["price_usd"] || asset[:price_usd] + source = asset["source"] || asset[:source] || "spot" + + return if symbol.blank? || total.zero? || price_usd.blank? + + security = resolve_security(symbol) + return unless security + + amount_usd = total * price_usd.to_d + amount, amount_stale, amount_rate_date = convert_from_usd(amount_usd, date: Date.current) + price, price_stale, price_rate_date = convert_from_usd(price_usd.to_d, date: Date.current) + log_stale_rate(symbol, "amount", amount_rate_date) if amount_stale + log_stale_rate(symbol, "price", price_rate_date) if price_stale + + import_adapter.import_holding( + security: security, + quantity: total, + amount: amount, + currency: target_currency, + date: Date.current, + price: price, + cost_basis: nil, + external_id: "kraken_#{symbol}_#{source}_#{Date.current}", + account_provider_id: kraken_account.account_provider&.id, + source: "kraken", + delete_future_holdings: false + ) + rescue StandardError => e + Rails.logger.error "KrakenAccount::HoldingsProcessor - failed asset symbol=#{symbol.presence || "unknown"}: #{e.message}" + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def resolve_security(symbol) + ticker = symbol.to_s.include?(":") ? symbol.to_s : "CRYPTO:#{symbol}" + KrakenAccount::SecurityResolver.resolve(ticker, symbol) + end + + def log_stale_rate(symbol, field, rate_date) + Rails.logger.warn( + "KrakenAccount::HoldingsProcessor - stale FX rate for #{field} symbol=#{symbol} rate_date=#{rate_date || "unknown"}" + ) + end +end diff --git a/app/models/kraken_account/processor.rb b/app/models/kraken_account/processor.rb new file mode 100644 index 000000000..483ac1f1e --- /dev/null +++ b/app/models/kraken_account/processor.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +class KrakenAccount::Processor + include KrakenAccount::UsdConverter + + attr_reader :kraken_account + + def initialize(kraken_account) + @kraken_account = kraken_account + end + + def process + return unless kraken_account.current_account.present? + + KrakenAccount::HoldingsProcessor.new(kraken_account).process + process_account! + process_trades + end + + private + + def target_currency + kraken_account.kraken_item&.family&.currency + end + + def process_account! + account = kraken_account.current_account + amount, stale, rate_date = convert_from_usd((kraken_account.current_balance || 0).to_d, date: Date.current) + + account.update!( + balance: amount, + cash_balance: 0, + currency: target_currency + ) + + kraken_account.update!(extra: kraken_account.extra.to_h.deep_merge(build_stale_extra(stale, rate_date, Date.current))) + end + + def process_trades + raw_trades.each do |txid, trade| + process_trade(txid, trade) + end + rescue StandardError => e + Rails.logger.error "KrakenAccount::Processor - trade processing failed: #{e.message}" + end + + def raw_trades + kraken_account.raw_transactions_payload&.dig("trades") || {} + end + + def process_trade(txid, trade) + account = kraken_account.current_account + return unless account + + external_id = "kraken_trade_#{txid}" + return if account.entries.exists?(external_id: external_id, source: "kraken") + + type = trade["type"].to_s.downcase + return unless %w[buy sell].include?(type) + + pair = trade["pair"].to_s + base_symbol, quote_symbol = infer_pair_symbols(pair, trade) + return if base_symbol.blank? + + qty = trade["vol"].to_d + return if qty.zero? + + price = trade["price"].to_d + cost = trade["cost"].presence&.to_d + cost ||= (qty * price).round(8) + fee = trade["fee"].presence&.to_d || 0 + currency = quote_symbol.presence || "USD" + date = Time.zone.at(trade["time"].to_d).to_date + security = KrakenAccount::SecurityResolver.resolve("CRYPTO:#{base_symbol}", base_symbol) + return unless security + + entry_amount = type == "buy" ? -cost : cost + trade_qty = type == "buy" ? qty : -qty + label = type == "buy" ? "Buy" : "Sell" + + account.entries.create!( + date: date, + name: "#{label} #{qty.round(8)} #{base_symbol}", + amount: entry_amount, + currency: currency, + external_id: external_id, + source: "kraken", + notes: trade["ordertxid"].presence, + entryable: Trade.new( + security: security, + qty: trade_qty, + price: price, + currency: currency, + fee: fee, + investment_activity_label: label + ) + ) + rescue StandardError => e + Rails.logger.error "KrakenAccount::Processor - failed to process trade #{txid}: #{e.message}" + end + + def infer_pair_symbols(pair, trade) + pair_metadata = kraken_account.raw_payload&.dig("pair_metadata") || {} + metadata = pair_metadata[pair] || pair_metadata.values.find { |candidate| candidate["altname"].to_s == pair } + normalizer = KrakenAccount::AssetNormalizer.new(kraken_account.raw_payload&.dig("asset_metadata") || {}) + + if metadata + base = normalizer.normalize(metadata["base"])[:symbol] + quote = normalizer.normalize(metadata["quote"])[:symbol] + return [ base, quote ] + end + + altname = trade["pair"].to_s + %w[USDT USDC USD EUR GBP BTC ETH].each do |quote| + next unless altname.end_with?(quote) + + return [ normalizer.normalize(altname.delete_suffix(quote))[:symbol], quote ] + end + + [ altname, "USD" ] + end +end diff --git a/app/models/kraken_account/security_resolver.rb b/app/models/kraken_account/security_resolver.rb new file mode 100644 index 000000000..036f9f986 --- /dev/null +++ b/app/models/kraken_account/security_resolver.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class KrakenAccount::SecurityResolver + EXCHANGE_MIC = "XKRA" + + def self.resolve(ticker, symbol) + Security::Resolver.new(ticker).resolve + rescue StandardError => e + Rails.logger.warn "KrakenAccount::SecurityResolver - resolver failed for #{ticker}: #{e.message}" + Security.find_or_initialize_by(ticker: ticker, exchange_operating_mic: EXCHANGE_MIC).tap do |security| + security.name = symbol if security.name.blank? + security.offline = true unless security.offline + security.save! if security.changed? + end + end +end diff --git a/app/models/kraken_account/usd_converter.rb b/app/models/kraken_account/usd_converter.rb new file mode 100644 index 000000000..054c587f0 --- /dev/null +++ b/app/models/kraken_account/usd_converter.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module KrakenAccount::UsdConverter + private + + def convert_from_usd(amount, date: Date.current) + return [ amount.to_d, false, nil ] if target_currency == "USD" + + rate = ExchangeRate.find_or_fetch_rate(from: "USD", to: target_currency, date: date) + return [ amount.to_d, true, nil ] if rate.nil? + + converted = Money.new(amount, "USD").exchange_to(target_currency, custom_rate: rate.rate).amount + stale = rate.date != date + rate_date = stale ? rate.date : nil + + [ converted, stale, rate_date ] + end + + def build_stale_extra(stale, rate_date, target_date) + kraken_meta = if stale + { + "stale_rate" => true, + "rate_date_used" => rate_date&.to_s, + "rate_target_date" => target_date&.to_s + } + else + { "stale_rate" => false } + end + + { "kraken" => kraken_meta } + end +end diff --git a/app/models/kraken_item.rb b/app/models/kraken_item.rb new file mode 100644 index 000000000..2c47d5f8b --- /dev/null +++ b/app/models/kraken_item.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +class KrakenItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + if encryption_ready? + encrypts :api_key, deterministic: true + encrypts :api_secret + encrypts :raw_payload + end + + validates :name, presence: true + validates :api_key, presence: true + validates :api_secret, presence: true + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + + has_many :kraken_accounts, dependent: :destroy + has_many :accounts, through: :kraken_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + scope :credentials_configured, -> { where.not(api_key: [ nil, "" ]).where.not(api_secret: nil) } + + before_validation :strip_credentials + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def import_latest_kraken_data + provider = kraken_provider + raise StandardError, "Kraken credentials not configured" unless provider + + KrakenItem::Importer.new(self, kraken_provider: provider).import + rescue StandardError => e + Rails.logger.error "KrakenItem #{id} - Failed to import: #{e.full_message}" + raise + end + + def process_accounts + return [] if kraken_accounts.empty? + + results = [] + kraken_accounts.joins(:account).merge(Account.visible).each do |kraken_account| + begin + result = KrakenAccount::Processor.new(kraken_account).process + results << { kraken_account_id: kraken_account.id, success: true, result: result } + rescue StandardError => e + Rails.logger.error "KrakenItem #{id} - Failed to process account #{kraken_account.id}: #{e.full_message}" + results << { kraken_account_id: kraken_account.id, success: false, error: e.message } + end + end + + results + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + accounts.visible.map do |account| + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + { account_id: account.id, success: true } + rescue StandardError => e + Rails.logger.error "KrakenItem #{id} - Failed to schedule sync for account #{account.id}: #{e.full_message}" + { account_id: account.id, success: false, error: e.message } + end + end + + def upsert_kraken_snapshot!(payload) + update!(raw_payload: payload) + end + + def has_completed_initial_setup? + accounts.any? + end + + def sync_status_summary + total = total_accounts_count + linked = linked_accounts_count + unlinked = unlinked_accounts_count + + if total.zero? + I18n.t("kraken_items.kraken_item.sync_status.no_accounts") + elsif unlinked.zero? + I18n.t("kraken_items.kraken_item.sync_status.all_synced", count: linked) + else + I18n.t("kraken_items.kraken_item.sync_status.partial_sync", linked_count: linked, unlinked_count: unlinked) + end + end + + def linked_accounts_count + kraken_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + kraken_accounts.count + end + + def stale_rate_accounts + kraken_accounts + .joins(:account) + .where(accounts: { status: "active" }) + .where("kraken_accounts.extra -> 'kraken' ->> 'stale_rate' = 'true'") + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + def credentials_configured? + api_key.to_s.strip.present? && api_secret.to_s.strip.present? + end + + def next_nonce! + with_lock do + candidate = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond) + candidate = last_nonce.to_i + 1 if candidate <= last_nonce.to_i + update!(last_nonce: candidate) + candidate.to_s + end + end + + def set_kraken_institution_defaults! + update!( + institution_name: "Kraken", + institution_domain: "kraken.com", + institution_url: "https://www.kraken.com", + institution_color: "#5841D8" + ) + end + + private + + def strip_credentials + self.api_key = api_key.to_s.strip if api_key_changed? && !api_key.nil? + self.api_secret = api_secret.to_s.strip if api_secret_changed? && !api_secret.nil? + end +end diff --git a/app/models/kraken_item/importer.rb b/app/models/kraken_item/importer.rb new file mode 100644 index 000000000..38944fe0f --- /dev/null +++ b/app/models/kraken_item/importer.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +class KrakenItem::Importer + MAX_TRADE_PAGES = 200 + TRADE_PAGE_SIZE = 50 + + attr_reader :kraken_item, :kraken_provider + + def initialize(kraken_item, kraken_provider:) + @kraken_item = kraken_item + @kraken_provider = kraken_provider + end + + def import + api_key_info = kraken_provider.get_api_key_info + + asset_metadata = kraken_provider.get_asset_info || {} + pair_metadata = kraken_provider.get_asset_pairs || {} + balances = kraken_provider.get_extended_balance || {} + assets = parse_assets(balances, asset_metadata) + trades = fetch_trades + + total_usd = assets.sum { |asset| asset[:amount_usd].to_d }.round(2) + kraken_account = upsert_kraken_account( + assets: assets, + balances: balances, + trades: trades, + asset_metadata: asset_metadata, + pair_metadata: pair_metadata, + api_key_info: api_key_info, + total_usd: total_usd + ) + + kraken_item.upsert_kraken_snapshot!({ + "api_key_info" => api_key_info, + "balances" => balances, + "asset_metadata" => asset_metadata, + "pair_metadata" => pair_metadata, + "imported_at" => Time.current.iso8601 + }) + + { success: true, account_id: kraken_account.id, assets_imported: assets.size, trades_imported: trades.size, total_usd: total_usd } + rescue Provider::Kraken::PermissionError => e + kraken_item.update!(status: :requires_update) + raise e + end + + private + def parse_assets(balances, asset_metadata) + normalizer = KrakenAccount::AssetNormalizer.new(asset_metadata) + + balances.filter_map do |raw_asset, balance_data| + parsed = normalizer.normalize(raw_asset) + balance = balance_data.fetch("balance", "0").to_d + credit = balance_data.fetch("credit", "0").to_d + credit_used = balance_data.fetch("credit_used", "0").to_d + hold_trade = balance_data.fetch("hold_trade", "0").to_d + available = balance + credit - credit_used - hold_trade + + next if balance.zero? && hold_trade.zero? + + price_usd, price_status = price_for(parsed[:price_symbol]) + amount_usd = price_usd ? (balance * price_usd).round(2) : 0.to_d + + parsed.merge( + balance: balance.to_s("F"), + available: available.to_s("F"), + hold_trade: hold_trade.to_s("F"), + price_usd: price_usd&.to_s("F"), + amount_usd: amount_usd.to_s("F"), + price_status: price_status, + source: "spot" + ) + end + end + + def price_for(symbol) + return [ 1.to_d, "exact" ] if symbol == "USD" || KrakenAccount::STABLECOINS.include?(symbol) + + if KrakenAccount::FIAT_CURRENCIES.include?(symbol) + rate = ExchangeRate.find_or_fetch_rate(from: symbol, to: "USD", date: Date.current) + return [ rate.rate.to_d, rate.date == Date.current ? "exact" : "stale" ] if rate + + return [ nil, "missing" ] + end + + ticker_price = ticker_price_for(symbol) + return [ ticker_price, "exact" ] if ticker_price + + [ nil, "missing" ] + rescue StandardError => e + Rails.logger.warn "KrakenItem::Importer - could not price #{symbol}: #{e.message}" + [ nil, "missing" ] + end + + def ticker_price_for(symbol) + pair_candidates_for(symbol).each do |pair| + response = kraken_provider.get_ticker(pair) + ticker_payload = response&.values&.first + price = ticker_payload&.dig("c", 0) + return price.to_d if price.present? + rescue Provider::Kraken::ApiError + next + end + + nil + end + + def pair_candidates_for(symbol) + kraken_symbol = symbol == "BTC" ? "XBT" : symbol + [ + "#{kraken_symbol}USD", + "#{symbol}USD", + "X#{kraken_symbol}ZUSD", + "#{kraken_symbol}USDT", + "#{symbol}USDT" + ].uniq + end + + def fetch_trades + start_time = kraken_item.sync_start_date&.to_i + offset = 0 + all_trades = {} + + MAX_TRADE_PAGES.times do + result = kraken_provider.get_trades_history(start: start_time, offset: offset) + trades = result.to_h.fetch("trades", {}) + duplicate_trade_ids = all_trades.keys & trades.keys + if duplicate_trade_ids.any? + Rails.logger.warn("KrakenItem::Importer - #{duplicate_trade_ids.size} duplicate trade ids from Kraken page ignored") + end + all_trades.merge!(trades.except(*duplicate_trade_ids)) + + count = result.to_h["count"].to_i + break if trades.size < TRADE_PAGE_SIZE + + offset += trades.size + break if count.positive? && offset >= count + end + + all_trades + end + + def upsert_kraken_account(assets:, balances:, trades:, asset_metadata:, pair_metadata:, api_key_info:, total_usd:) + kraken_item.kraken_accounts.find_or_initialize_by(account_id: "combined").tap do |account| + account.assign_attributes( + name: kraken_item.institution_name.presence || "Kraken", + account_type: "combined", + currency: "USD", + current_balance: total_usd, + institution_metadata: institution_metadata(assets), + raw_payload: { + "balances" => balances, + "assets" => assets.map(&:stringify_keys), + "asset_metadata" => asset_metadata, + "pair_metadata" => pair_metadata, + "api_key_info" => api_key_info, + "fetched_at" => Time.current.iso8601 + }, + raw_transactions_payload: { + "trades" => trades, + "fetched_at" => Time.current.iso8601 + }, + extra: account.extra.to_h.deep_merge(price_metadata(assets)) + ) + account.save! + end + end + + def institution_metadata(assets) + { + "name" => "Kraken", + "domain" => "kraken.com", + "url" => "https://www.kraken.com", + "color" => "#5841D8", + "asset_count" => assets.size, + "assets" => assets.map { |asset| asset[:symbol] } + } + end + + def price_metadata(assets) + missing = assets.select { |asset| asset[:price_status] == "missing" }.map { |asset| asset[:symbol] } + stale = assets.select { |asset| asset[:price_status] == "stale" }.map { |asset| asset[:symbol] } + + { "kraken" => { "missing_prices" => missing, "stale_prices" => stale } } + end +end diff --git a/app/models/kraken_item/provided.rb b/app/models/kraken_item/provided.rb new file mode 100644 index 000000000..830a6fd11 --- /dev/null +++ b/app/models/kraken_item/provided.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module KrakenItem::Provided + extend ActiveSupport::Concern + + def kraken_provider + return nil unless credentials_configured? + + Provider::Kraken.new( + api_key: api_key.to_s.strip, + api_secret: api_secret.to_s.strip, + nonce_generator: -> { next_nonce! } + ) + end +end diff --git a/app/models/kraken_item/sync_complete_event.rb b/app/models/kraken_item/sync_complete_event.rb new file mode 100644 index 000000000..a2b07d69e --- /dev/null +++ b/app/models/kraken_item/sync_complete_event.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class KrakenItem::SyncCompleteEvent + def initialize(kraken_item) + raise ArgumentError, "kraken_item is required" unless kraken_item.respond_to?(:family) && kraken_item.respond_to?(:id) + + @kraken_item = kraken_item + end + + def broadcast + Turbo::StreamsChannel.broadcast_replace_to( + @kraken_item.family, + target: ActionView::RecordIdentifier.dom_id(@kraken_item), + partial: "kraken_items/kraken_item", + locals: { kraken_item: @kraken_item } + ) + rescue StandardError => e + Rails.logger.warn("KrakenItem::SyncCompleteEvent failed for #{@kraken_item.id}: #{e.class}") + end +end diff --git a/app/models/kraken_item/syncer.rb b/app/models/kraken_item/syncer.rb new file mode 100644 index 000000000..80b066d36 --- /dev/null +++ b/app/models/kraken_item/syncer.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class KrakenItem::Syncer + include SyncStats::Collector + + attr_reader :kraken_item + + def initialize(kraken_item) + @kraken_item = kraken_item + end + + def perform_sync(sync) + sync.update!(status_text: I18n.t("kraken_item.syncer.checking_credentials")) if sync.respond_to?(:status_text) + unless kraken_item.credentials_configured? + kraken_item.update!(status: :requires_update) + mark_failed(sync, I18n.t("kraken_item.syncer.credentials_invalid")) + return + end + + sync.update!(status_text: I18n.t("kraken_item.syncer.importing_accounts")) if sync.respond_to?(:status_text) + kraken_item.import_latest_kraken_data + kraken_item.update!(status: :good) if kraken_item.requires_update? + + sync.update!(status_text: I18n.t("kraken_item.syncer.checking_configuration")) if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: kraken_item.kraken_accounts.to_a) + + unlinked = kraken_item.kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + linked = kraken_item.kraken_accounts.joins(:account_provider).joins(:account).merge(Account.visible) + + if unlinked.any? + kraken_item.update!(pending_account_setup: true) + sync.update!(status_text: I18n.t("kraken_item.syncer.accounts_need_setup", count: unlinked.count)) if sync.respond_to?(:status_text) + else + kraken_item.update!(pending_account_setup: false) + end + + return unless linked.any? + + sync.update!(status_text: I18n.t("kraken_item.syncer.processing_accounts")) if sync.respond_to?(:status_text) + kraken_item.process_accounts + + sync.update!(status_text: I18n.t("kraken_item.syncer.calculating_balances")) if sync.respond_to?(:status_text) + kraken_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + + account_ids = linked.map { |kraken_account| kraken_account.current_account&.id }.compact + if account_ids.any? + collect_transaction_stats(sync, account_ids: account_ids, source: "kraken") + collect_trades_stats(sync, account_ids: account_ids, source: "kraken") + end + rescue Provider::Kraken::AuthenticationError, Provider::Kraken::PermissionError, Provider::Kraken::OTPRequiredError => e + kraken_item.update!(status: :requires_update) + mark_failed(sync, e.message) + raise + rescue StandardError => e + Rails.logger.error "KrakenItem::Syncer - unexpected error during sync: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}" + mark_failed(sync, e.message) + raise + end + + def perform_post_sync + end + + private + + def mark_failed(sync, error_message) + sync.start! if sync.respond_to?(:may_start?) && sync.may_start? + + if sync.respond_to?(:may_fail?) && sync.may_fail? + sync.fail! + elsif sync.respond_to?(:status) + sync.update!(status: :failed) + end + + sync.update!(error: error_message) if sync.respond_to?(:error) + sync.update!(status_text: error_message) if sync.respond_to?(:status_text) + end +end diff --git a/app/models/kraken_item/unlinking.rb b/app/models/kraken_item/unlinking.rb new file mode 100644 index 000000000..d3ef80bb7 --- /dev/null +++ b/app/models/kraken_item/unlinking.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module KrakenItem::Unlinking + extend ActiveSupport::Concern + + def unlink_all!(dry_run: false) + results = [] + links_by_provider_id = AccountProvider + .where(provider_type: KrakenAccount.name, provider_id: kraken_accounts.select(:id)) + .group_by { |link| link.provider_id.to_s } + + kraken_accounts.find_each do |provider_account| + links = links_by_provider_id[provider_account.id.to_s] || [] + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) if link_ids.any? + links.each(&:destroy!) + end + rescue StandardError => e + Rails.logger.warn("KrakenItem Unlinker: failed to unlink ##{provider_account.id}: #{e.class} - #{e.message}") + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/provider/kraken.rb b/app/models/provider/kraken.rb new file mode 100644 index 000000000..38a2db6ca --- /dev/null +++ b/app/models/provider/kraken.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +class Provider::Kraken + include HTTParty + extend SslConfigurable + + class Error < StandardError; end + class AuthenticationError < Error; end + class PermissionError < Error; end + class RateLimitError < Error; end + class NonceError < Error; end + class OTPRequiredError < Error; end + class ApiError < Error; end + + BASE_URL = "https://api.kraken.com" + PRIVATE_PREFIX = "/0/private" + PUBLIC_PREFIX = "/0/public" + + base_uri BASE_URL + default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options)) + + attr_reader :api_key, :api_secret + + def initialize(api_key:, api_secret:, nonce_generator: nil) + @api_key = api_key # pipelock:ignore user-supplied Kraken credential kept in memory for signed requests + @api_secret = api_secret # pipelock:ignore user-supplied Kraken credential kept in memory for signed requests + @nonce_generator = nonce_generator || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s } + end + + def get_api_key_info + private_post("GetApiKeyInfo") + end + + def get_extended_balance + private_post("BalanceEx") + end + + def get_trades_history(start: nil, offset: nil) + params = {} + params["start"] = start.to_i.to_s if start.present? + params["ofs"] = offset.to_i.to_s if offset.present? + + private_post("TradesHistory", params) + end + + def get_asset_info(asset: nil) + params = {} + params["asset"] = asset if asset.present? + public_get("Assets", params) + end + + def get_asset_pairs(pair: nil) + params = {} + params["pair"] = pair if pair.present? + public_get("AssetPairs", params) + end + + def get_ticker(pair) + public_get("Ticker", "pair" => pair) + end + + def get_ohlc(pair, interval: 1440, since: nil) + params = { "pair" => pair, "interval" => interval.to_s } + params["since"] = since.to_i.to_s if since.present? + public_get("OHLC", params) + end + + private + + attr_reader :nonce_generator + + def public_get(method, params = {}) + response = self.class.get("#{PUBLIC_PREFIX}/#{method}", query: params) + handle_response(response) + end + + def private_post(method, params = {}) + path = "#{PRIVATE_PREFIX}/#{method}" + request_params = { "nonce" => nonce_generator.call.to_s }.merge(stringify_params(params)) + body = URI.encode_www_form(request_params) + + response = self.class.post( + path, + body: body, + headers: auth_headers(path, request_params).merge("Content-Type" => "application/x-www-form-urlencoded") + ) + + handle_response(response) + end + + def stringify_params(params) + params.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value.to_s } + end + + def auth_headers(path, params) + { + "API-Key" => api_key, + "API-Sign" => sign(path, params) + } + end + + def sign(path, params) + encoded_payload = URI.encode_www_form(params) + nonce = params.fetch("nonce").to_s + digest = OpenSSL::Digest::SHA256.digest(nonce + encoded_payload) + hmac = OpenSSL::HMAC.digest("sha512", Base64.decode64(api_secret), path + digest) + Base64.strict_encode64(hmac) + end + + def handle_response(response) + parsed = response.parsed_response + + unless response.code.between?(200, 299) + raise ApiError, "Kraken API request failed: #{response.code}" + end + + unless parsed.is_a?(Hash) + raise ApiError, "Malformed Kraken API response" + end + + unless parsed.key?("error") + raise ApiError, "Malformed Kraken API response: missing error" + end + + errors = Array(parsed["error"]).reject(&:blank?) + raise classified_error(errors) if errors.any? + + unless parsed.key?("result") + raise ApiError, "Malformed Kraken API response: missing result" + end + + parsed["result"] + end + + def classified_error(errors) + message = errors.join(", ") + + case message + when /Invalid key|Invalid signature|Temporary lockout/i + AuthenticationError.new(message) + when /Invalid nonce/i + NonceError.new(message) + when /Permission denied|Invalid permissions/i + PermissionError.new(message) + when /Rate limit exceeded|Too many requests|limit exceeded|Throttled/i + RateLimitError.new(message) + when /otp|2fa|two.factor/i + OTPRequiredError.new(message) + else + ApiError.new(message) + end + end +end diff --git a/app/models/provider/kraken_adapter.rb b/app/models/provider/kraken_adapter.rb new file mode 100644 index 000000000..c932a9efb --- /dev/null +++ b/app/models/provider/kraken_adapter.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +class Provider::KrakenAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + Provider::Factory.register("KrakenAccount", self) + + def self.supported_account_types + %w[Crypto] + end + + def self.connection_configs(family:) + return [] unless family.can_connect_kraken? + + kraken_items = family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?) + return [ connection_config_for(nil) ] if kraken_items.empty? + + kraken_items.map { |kraken_item| connection_config_for(kraken_item) } + end + + def self.build_provider(family: nil, kraken_item_id: nil) + return nil unless family.present? + + kraken_item = resolve_kraken_item(family, kraken_item_id) + return nil unless kraken_item&.credentials_configured? + + kraken_item.kraken_provider + end + + def provider_name + "kraken" + end + + def sync_path + return unless item + + Rails.application.routes.url_helpers.sync_kraken_item_path(item) + end + + def item + provider_account.kraken_item + end + + def can_delete_holdings? + false + end + + def institution_domain + institution_metadata_value("domain") + end + + def institution_name + institution_metadata_value("name") + end + + def institution_url + institution_metadata_value("url") + end + + def institution_color + institution_metadata_value("color") + end + + def self.connection_config_for(kraken_item) + path_params = ->(extra = {}) do + kraken_item.present? ? extra.merge(kraken_item_id: kraken_item.id) : extra + end + + { + key: kraken_item.present? ? "kraken_#{kraken_item.id}" : "kraken", + name: kraken_item.present? ? I18n.t("kraken_items.provider_connection.name", name: kraken_item.name) : I18n.t("kraken_items.provider_connection.default_name"), + description: kraken_item.present? ? I18n.t("kraken_items.provider_connection.description", name: kraken_item.name) : I18n.t("kraken_items.provider_connection.default_description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_kraken_items_path( + path_params.call(accountable_type: accountable_type, return_to: return_to) + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_kraken_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + def self.resolve_kraken_item(family, kraken_item_id) + if kraken_item_id.present? + item = family.kraken_items.active.credentials_configured.find_by(id: kraken_item_id) + return item if item&.credentials_configured? + + return nil + end + + credentialed_items = family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?) + return credentialed_items.first if credentialed_items.one? + + nil + end + private_class_method :resolve_kraken_item + + private + + def institution_metadata_value(key) + metadata = provider_account.institution_metadata || {} + metadata[key] || item&.public_send("institution_#{key}") + end +end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb index 5cdd8e72c..0850c524c 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -8,6 +8,7 @@ class Provider mercury: { region: "US", kind: "Bank", maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" }, coinbase: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" }, binance: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" }, + kraken: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "KR", logo_bg: "bg-violet-600" }, snaptrade: { region: "US / CA", kind: "Investment", maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" }, indexa_capital: { region: "ES", kind: "Investment", maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" }, sophtron: { region: "US", kind: "Bank", maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" }, diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index e1a4b4100..6ed6f4a34 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -8,6 +8,7 @@ class ProviderConnectionStatus { key: "enable_banking", type: "EnableBankingItem", association: :enable_banking_items, accounts: :enable_banking_accounts }, { key: "coinbase", type: "CoinbaseItem", association: :coinbase_items, accounts: :coinbase_accounts }, { key: "binance", type: "BinanceItem", association: :binance_items, accounts: :binance_accounts }, + { key: "kraken", type: "KrakenItem", association: :kraken_items, accounts: :kraken_accounts }, { key: "coinstats", type: "CoinstatsItem", association: :coinstats_items, accounts: :coinstats_accounts }, { key: "snaptrade", type: "SnaptradeItem", association: :snaptrade_items, accounts: :snaptrade_accounts, linked_accounts: :linked_accounts }, { key: "mercury", type: "MercuryItem", association: :mercury_items, accounts: :mercury_accounts }, diff --git a/app/views/kraken_items/_kraken_item.html.erb b/app/views/kraken_items/_kraken_item.html.erb new file mode 100644 index 000000000..590d900fa --- /dev/null +++ b/app/views/kraken_items/_kraken_item.html.erb @@ -0,0 +1,115 @@ +<%# locals: (kraken_item:, unlinked_count: kraken_item.unlinked_accounts_count) %> + +<%= tag.div id: dom_id(kraken_item) do %> +
+ + <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+ <%= icon "waves", size: "sm", class: "text-primary" %> +
+ +
+
+ <%= tag.p kraken_item.institution_display_name, class: "font-medium text-primary" %> + <% if kraken_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+

<%= t(".provider_name") %>

+ + <% if kraken_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif kraken_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".reconnect") %> +
+ <% else %> +

+ <% if kraken_item.last_synced_at %> + <% if kraken_item.sync_status_summary %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(kraken_item.last_synced_at), summary: kraken_item.sync_status_summary) %> + <% else %> + <%= t(".status", timestamp: time_ago_in_words(kraken_item.last_synced_at)) %> + <% end %> + <% else %> + <%= t(".status_never") %> + <% end %> +

+ <% end %> +
+
+ + <% if Current.user&.admin? %> +
+ <%= icon( + "refresh-cw", + as_button: true, + href: sync_kraken_item_path(kraken_item), + disabled: kraken_item.syncing? + ) %> + + <%= render DS::Menu.new do |menu| %> + <% if unlinked_count.to_i > 0 %> + <% menu.with_item( + variant: "link", + text: t(".import_accounts_menu"), + icon: "plus", + href: setup_accounts_kraken_item_path(kraken_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: kraken_item_path(kraken_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(kraken_item.institution_display_name, high_severity: true) + ) %> + <% end %> +
+ <% end %> + + <% unless kraken_item.scheduled_for_deletion? %> +
+ <% if kraken_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: kraken_item.accounts %> + <% kraken_item.stale_rate_accounts.each do |kraken_account| %> +
+ ~ + <%= icon "triangle-alert", size: "sm" %> + <%= t(".stale_rate_warning", date: kraken_account.extra.dig("kraken", "rate_target_date")) %> +
+ <% end %> + <% end %> + + <% stats = kraken_item.syncs.ordered.first&.sync_stats || {} %> + <%= render ProviderSyncSummary.new(stats: stats, provider_item: kraken_item) %> + + <% if unlinked_count.to_i > 0 && kraken_item.accounts.empty? %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description") %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "plus", + variant: "primary", + href: setup_accounts_kraken_item_path(kraken_item), + frame: :modal + ) %> +
+ <% elsif kraken_item.accounts.empty? && kraken_item.kraken_accounts.none? %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_message") %>

+
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/kraken_items/select_existing_account.html.erb b/app/views/kraken_items/select_existing_account.html.erb new file mode 100644 index 000000000..d7e9e5807 --- /dev/null +++ b/app/views/kraken_items/select_existing_account.html.erb @@ -0,0 +1,42 @@ +<%# Modal: Link an existing manual crypto exchange account to a Kraken account %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> + <% if @available_kraken_accounts.blank? %> +
+

<%= t(".no_accounts_found") %>

+ +
+ <% else %> + <%= form_with url: link_existing_account_kraken_items_path, method: :post, class: "space-y-4" do %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :kraken_item_id, @kraken_item.id %> + +
+ <% @available_kraken_accounts.each do |kraken_account| %> + + <% end %> +
+ +
+ <%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %> + <%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %> +
+ <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/kraken_items/setup_accounts.html.erb b/app/views/kraken_items/setup_accounts.html.erb new file mode 100644 index 000000000..5b757f492 --- /dev/null +++ b/app/views/kraken_items/setup_accounts.html.erb @@ -0,0 +1,89 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "waves", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: complete_account_setup_kraken_item_path(@kraken_item), + method: :post, + local: true, + id: "kraken-setup-form", + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t(".creating"), + turbo_frame: "_top" + }, + class: "space-y-6" do |form| %> +
+
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +

<%= t(".instructions") %>

+
+
+ + <% if @kraken_accounts.empty? %> +
+

<%= t(".no_accounts") %>

+
+ <% else %> +
+
+ + <%= t(".accounts_count", count: @kraken_accounts.count) %> + + +
+ +
+ <% @kraken_accounts.each do |kraken_account| %> + + <% end %> +
+
+ <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".import_selected"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new(text: t(".cancel"), variant: "secondary", href: accounts_path) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/settings/providers/_kraken_panel.html.erb b/app/views/settings/providers/_kraken_panel.html.erb new file mode 100644 index 000000000..7de8e7831 --- /dev/null +++ b/app/views/settings/providers/_kraken_panel.html.erb @@ -0,0 +1,141 @@ +
+ <% items = local_assigns[:kraken_items] || @kraken_items || Current.family.kraken_items.active.ordered %> + + <%= render DS::Alert.new( + variant: :warning, + message: safe_join([ + content_tag(:p, t("settings.providers.kraken_panel.read_only_title"), class: "font-medium"), + content_tag(:p, t("settings.providers.kraken_panel.read_only_body"), class: "mt-1") + ]) + ) %> + + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.kraken_panel.step1_html").html_safe, + t("settings.providers.kraken_panel.step2"), + t("settings.providers.kraken_panel.step3") + ] %> + + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> + <%= render DS::Alert.new(message: error_msg, variant: :error) %> + <% end %> + + <% if items.any? %> +
+ <% items.each do |item| %> +
+ +
+
+ <%= icon "waves", size: "sm", class: "text-primary" %> +
+
+

<%= item.name %>

+

+ <% if item.syncing? %> + <%= t("settings.providers.kraken_panel.syncing") %> + <% else %> + <%= item.sync_status_summary %> + <% end %> +

+
+
+
+ +
+
+ <%= button_to sync_kraken_item_path(item), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary", + disabled: item.syncing? do %> + <%= icon "refresh-cw", size: "sm" %> + <%= t("settings.providers.kraken_panel.sync") %> + <% end %> + + <%= render DS::Link.new( + text: t("settings.providers.kraken_panel.setup_accounts"), + icon: "settings", + variant: "secondary", + href: setup_accounts_kraken_item_path(item), + frame: :modal + ) %> + + <%= button_to kraken_item_path(item), + method: :delete, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg", + data: { turbo_confirm: t("settings.providers.kraken_panel.disconnect_confirm", name: item.name) } do %> + <%= icon "trash-2", size: "sm" %> + <%= t("settings.providers.kraken_panel.disconnect") %> + <% end %> +
+ + <%= styled_form_with model: item, + url: kraken_item_path(item), + scope: :kraken_item, + method: :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("settings.providers.kraken_panel.connection_name_label"), + placeholder: t("settings.providers.kraken_panel.connection_name_placeholder") %> + + <%= form.text_field :api_key, + label: t("settings.providers.kraken_panel.api_key_label"), + placeholder: t("settings.providers.kraken_panel.keep_api_key_placeholder"), + type: :password, + value: nil %> + + <%= form.text_field :api_secret, + label: t("settings.providers.kraken_panel.api_secret_label"), + placeholder: t("settings.providers.kraken_panel.keep_api_secret_placeholder"), + type: :password, + value: nil %> + +
+ <%= form.submit t("settings.providers.kraken_panel.update_connection"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> +
+ <% end %> +
+
+ <% end %> +
+ <% end %> + + <% kraken_item = Current.family.kraken_items.build(name: t("settings.providers.kraken_panel.default_connection_name")) %> + <% if items.any? %> +

+ <%= icon "plus", size: "sm" %> + <%= t("settings.providers.kraken_panel.add_connection") %> +

+ <% end %> + + <%= styled_form_with model: kraken_item, + url: kraken_items_path, + scope: :kraken_item, + method: :post, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("settings.providers.kraken_panel.connection_name_label"), + placeholder: t("settings.providers.kraken_panel.connection_name_placeholder") %> + + <%= form.text_field :api_key, + label: t("settings.providers.kraken_panel.api_key_label"), + placeholder: t("settings.providers.kraken_panel.api_key_placeholder"), + type: :password, + value: nil %> + + <%= form.text_field :api_secret, + label: t("settings.providers.kraken_panel.api_secret_label"), + placeholder: t("settings.providers.kraken_panel.api_secret_placeholder"), + type: :password, + value: nil %> + +
+ <%= form.submit t("settings.providers.kraken_panel.add_connection"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> +
+ <% end %> +
diff --git a/config/locales/views/kraken_items/en.yml b/config/locales/views/kraken_items/en.yml new file mode 100644 index 000000000..eec60a9e4 --- /dev/null +++ b/config/locales/views/kraken_items/en.yml @@ -0,0 +1,85 @@ +--- +en: + kraken_items: + provider_connection: + default_name: Kraken + default_description: Link to a Kraken exchange account + name: "Kraken - %{name}" + description: "Link to %{name}" + create: + default_name: Kraken + success: Successfully connected to Kraken. Your exchange account is being synced. + update: + success: Successfully updated Kraken connection. + destroy: + success: Scheduled Kraken connection for deletion. + select_accounts: + select_connection: Choose a Kraken connection in Provider Settings. + no_credentials_configured: Add Kraken API credentials before setting up accounts. + link_accounts: + select_connection: Choose a Kraken connection before linking accounts. + select_existing_account: + title: Link Kraken Account + no_accounts_found: No Kraken accounts found. + wait_for_sync: Wait for Kraken to finish syncing. + check_provider_health: Check that your Kraken API credentials are valid. + link: Link + cancel: Cancel + link_existing_account: + success: Successfully linked to Kraken account + select_connection: Choose a Kraken connection before linking accounts. + errors: + only_manual: Only manual Crypto exchange accounts without an existing provider link can be linked to Kraken + invalid_kraken_account: Invalid Kraken account + kraken_account_already_linked: This Kraken account is already linked + setup_accounts: + title: Import Kraken Account + subtitle: Select the exchange account to track + instructions: Kraken imports one combined Crypto exchange account for this connection, with holdings and spot trade fills only. + no_accounts: All Kraken accounts have been imported. + accounts_count: + one: "%{count} account available" + other: "%{count} accounts available" + select_all: Select all + import_selected: Import Selected + cancel: Cancel + creating: Importing... + complete_account_setup: + success: + one: "Imported %{count} account" + other: "Imported %{count} accounts" + none_selected: No accounts selected + no_accounts: No accounts to import + kraken_item: + provider_name: Kraken + syncing: Syncing... + reconnect: Credentials need updating + deletion_in_progress: Deleting... + sync_status: + no_accounts: No accounts found + all_synced: + one: "%{count} account synced" + other: "%{count} accounts synced" + partial_sync: "%{linked_count} synced, %{unlinked_count} need setup" + status: "Last synced %{timestamp} ago" + status_with_summary: "Last synced %{timestamp} ago - %{summary}" + status_never: Never synced + delete: Delete + no_accounts_title: No accounts found + no_accounts_message: Your Kraken exchange account will appear here after syncing. + setup_needed: Account ready to import + setup_description: Import this Kraken connection as a Crypto exchange account. + setup_action: Import Account + import_accounts_menu: Import Account + stale_rate_warning: "Balance is approximate because the exact exchange rate for %{date} was unavailable. Will update on next sync." + kraken_item: + syncer: + checking_credentials: Checking credentials... + credentials_invalid: Invalid Kraken API credentials. Please check your API key and secret. + importing_accounts: Importing accounts from Kraken... + checking_configuration: Checking account configuration... + accounts_need_setup: + one: "%{count} account needs setup" + other: "%{count} accounts need setup" + processing_accounts: Processing account data... + calculating_balances: Calculating balances... diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index f3753374a..87114313b 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -235,6 +235,7 @@ en: mercury: Sync your Mercury business banking accounts automatically. coinbase: Import your Coinbase crypto holdings and track performance. binance: Sync your Binance spot balances using a read-only API key. + kraken: Sync Kraken balances and spot trade fills using a read-only API key. snaptrade: Connect brokerage accounts via the SnapTrade aggregation network. indexa_capital: Track your Indexa Capital automated investment portfolio. sophtron: Connect US & Canadian banks and utilities. @@ -284,6 +285,28 @@ en: syncing: Syncing... sync: Sync disconnect_confirm: "Are you sure you want to disconnect Binance?" + kraken_panel: + step1_html: 'Go to Kraken API settings' + step2: "Create an API key with Query Funds and Query Closed Orders & Trades only." + step3: "Paste the API key and private key below." + read_only_title: "Read-only exchange sync only" + read_only_body: "Do not grant trading, cancellation, withdrawal, export, ledger, Earn, staking, or transfer permissions. Sure only imports balances, holdings, and spot trade fills." + default_connection_name: Kraken + add_connection: Add Kraken connection + update_connection: Update connection + connection_name_label: Connection name + connection_name_placeholder: Main Kraken + api_key_label: API Key + api_key_placeholder: Paste your Kraken API key + keep_api_key_placeholder: Leave blank to keep the existing API key + api_secret_label: Private Key + api_secret_placeholder: Paste your Kraken private key + keep_api_secret_placeholder: Leave blank to keep the existing private key + setup_accounts: Setup account + syncing: Syncing... + sync: Sync + disconnect: Disconnect + disconnect_confirm: "Are you sure you want to disconnect %{name}?" enable_banking_panel: callback_url_instruction: "For the callback URL, use %{callback_url}." connection_error: Connection Error diff --git a/config/routes.rb b/config/routes.rb index 93f8655e7..1ab28a7c1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -64,6 +64,21 @@ Rails.application.routes.draw do end end + resources :kraken_items, only: [ :create, :update, :destroy ] do + collection do + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end + resources :snaptrade_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do collection do get :preload_accounts diff --git a/db/migrate/20260511090000_create_kraken_items_and_accounts.rb b/db/migrate/20260511090000_create_kraken_items_and_accounts.rb new file mode 100644 index 000000000..48890a85c --- /dev/null +++ b/db/migrate/20260511090000_create_kraken_items_and_accounts.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class CreateKrakenItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + create_table :kraken_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + + t.string :status, default: "good", null: false + t.boolean :scheduled_for_deletion, default: false, null: false + t.boolean :pending_account_setup, default: false, null: false + + t.datetime :sync_start_date + t.jsonb :raw_payload + + t.text :api_key + t.text :api_secret + t.bigint :last_nonce, default: 0, null: false + + t.timestamps + end + + add_index :kraken_items, :status + + create_table :kraken_accounts, id: :uuid do |t| + t.references :kraken_item, null: false, foreign_key: true, type: :uuid + + t.string :name + t.string :account_id, null: false + t.string :account_type + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + t.jsonb :extra, default: {}, null: false + + t.timestamps + end + + add_index :kraken_accounts, :account_type + add_index :kraken_accounts, + [ :kraken_item_id, :account_id ], + unique: true, + name: "index_kraken_accounts_on_item_and_account_id" + end +end diff --git a/db/schema.rb b/db/schema.rb index b40605ab9..1dbf42e98 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_05_11_090000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -201,9 +201,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do t.string "institution_domain" t.string "institution_url" t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false t.datetime "sync_start_date" t.jsonb "raw_payload" t.text "api_key" @@ -853,6 +853,45 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do t.index ["token_digest"], name: "index_invite_codes_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)" end + create_table "kraken_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "kraken_item_id", null: false + t.string "name" + t.string "account_id", null: false + t.string "account_type" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.jsonb "extra", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_type"], name: "index_kraken_accounts_on_account_type" + t.index ["kraken_item_id", "account_id"], name: "index_kraken_accounts_on_item_and_account_id", unique: true + t.index ["kraken_item_id"], name: "index_kraken_accounts_on_kraken_item_id" + end + + create_table "kraken_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.text "api_key" + t.text "api_secret" + t.bigint "last_nonce", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_kraken_items_on_family_id" + t.index ["status"], name: "index_kraken_items_on_status" + end + create_table "llm_usages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "provider", null: false @@ -1718,6 +1757,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do add_foreign_key "indexa_capital_items", "families" add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" + add_foreign_key "kraken_accounts", "kraken_items" + add_foreign_key "kraken_items", "families" add_foreign_key "llm_usages", "families" add_foreign_key "lunchflow_accounts", "lunchflow_items" add_foreign_key "lunchflow_items", "families" diff --git a/test/controllers/api/v1/provider_connections_controller_test.rb b/test/controllers/api/v1/provider_connections_controller_test.rb index 815aeb9e7..ebd637380 100644 --- a/test/controllers/api/v1/provider_connections_controller_test.rb +++ b/test/controllers/api/v1/provider_connections_controller_test.rb @@ -104,12 +104,28 @@ class Api::V1::ProviderConnectionsControllerTest < ActionDispatch::IntegrationTe failed_at: Time.current, error: "raw provider token secret" ) + kraken_item = kraken_items(:one) + kraken_item.syncs.create!( + status: "failed", + failed_at: Time.current, + error: "raw kraken key secret" + ) get api_v1_provider_connections_url, headers: api_headers(@api_key) assert_response :success + json_response = JSON.parse(response.body) + kraken_connection = json_response["data"].detect do |connection| + connection["id"] == kraken_item.id && connection["provider"] == "kraken" + end + + assert_not_nil kraken_connection + assert_equal "KrakenItem", kraken_connection["provider_type"] refute_includes response.body, @mercury_item.token + refute_includes response.body, kraken_item.api_key + refute_includes response.body, kraken_item.api_secret refute_includes response.body, "raw provider token secret" + refute_includes response.body, "raw kraken key secret" end test "fails closed when credential readiness is unknown" do diff --git a/test/controllers/kraken_items_controller_test.rb b/test/controllers/kraken_items_controller_test.rb new file mode 100644 index 000000000..e287fcecb --- /dev/null +++ b/test/controllers/kraken_items_controller_test.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require "test_helper" + +class KrakenItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + SyncJob.stubs(:perform_later) + + @family = families(:dylan_family) + @existing_item = kraken_items(:one) + kraken_items(:requires_update).update!(scheduled_for_deletion: true) + @second_item = KrakenItem.create!( + family: @family, + name: "Business Kraken", + api_key: "second_kraken_key", + api_secret: "second_kraken_secret" + ) + end + + test "create adds a new kraken connection without overwriting existing credentials" do + existing_key = @existing_item.api_key + existing_secret = @existing_item.api_secret + + assert_difference "KrakenItem.count", 1 do + post kraken_items_url, params: { + kraken_item: { + name: "Joint Kraken", + api_key: "joint_kraken_key", + api_secret: "joint_kraken_secret" + } + } + end + + assert_redirected_to settings_providers_path + assert_equal existing_key, @existing_item.reload.api_key + assert_equal existing_secret, @existing_item.api_secret + assert_equal "joint_kraken_key", @family.kraken_items.find_by!(name: "Joint Kraken").api_key + end + + test "update changes only the selected kraken connection" do + existing_key = @existing_item.api_key + + patch kraken_item_url(@second_item), params: { + kraken_item: { + name: "Renamed Business Kraken", + api_key: "updated_second_key", + api_secret: "updated_second_secret" + } + } + + assert_redirected_to settings_providers_path + assert_equal existing_key, @existing_item.reload.api_key + assert_equal "Renamed Business Kraken", @second_item.reload.name + assert_equal "updated_second_key", @second_item.api_key + assert_equal "updated_second_secret", @second_item.api_secret + end + + test "blank secret update preserves the selected kraken credentials" do + original_key = @second_item.api_key + original_secret = @second_item.api_secret + + patch kraken_item_url(@second_item), params: { + kraken_item: { + name: "Renamed Business Kraken", + api_key: "", + api_secret: "" + } + } + + assert_redirected_to settings_providers_path + assert_equal "Renamed Business Kraken", @second_item.reload.name + assert_equal original_key, @second_item.api_key + assert_equal original_secret, @second_item.api_secret + end + + test "create rejects whitespace-only credentials" do + assert_no_difference "KrakenItem.count" do + post kraken_items_url, params: { + kraken_item: { + name: "Blank Kraken", + api_key: " ", + api_secret: "\n" + } + } + end + + assert_redirected_to settings_providers_path + assert_match(/API key can't be blank/i, flash[:alert]) + end + + test "select accounts requires an explicit connection when multiple kraken items exist" do + get select_accounts_kraken_items_url, params: { accountable_type: "Crypto" } + + assert_redirected_to settings_providers_path + assert_equal "Choose a Kraken connection in Provider Settings.", flash[:alert] + end + + test "select accounts targets selected kraken item" do + get select_accounts_kraken_items_url, params: { + kraken_item_id: @second_item.id, + accountable_type: "Crypto" + } + + assert_redirected_to setup_accounts_kraken_item_path(@second_item, return_to: nil) + end + + test "select accounts rejects protocol-relative return paths" do + get select_accounts_kraken_items_url, params: { + kraken_item_id: @second_item.id, + accountable_type: "Crypto", + return_to: "//evil.example/accounts" + } + + assert_redirected_to setup_accounts_kraken_item_path(@second_item, return_to: nil) + end + + test "sync only queues a sync for the selected kraken item" do + assert_difference -> { Sync.where(syncable: @second_item).count }, 1 do + assert_no_difference -> { Sync.where(syncable: @existing_item).count } do + post sync_kraken_item_url(@second_item) + end + end + + assert_response :redirect + end + + test "setup accounts creates crypto exchange account for selected item only" do + first_account = kraken_accounts(:one) + second_account = @second_item.kraken_accounts.create!( + name: "Second Kraken", + account_id: "combined", + account_type: "combined", + currency: "USD", + current_balance: 1000 + ) + KrakenAccount::Processor.any_instance.stubs(:process).returns(nil) + + assert_difference "Account.count", 1 do + post complete_account_setup_kraken_item_url(@second_item), params: { + selected_accounts: [ second_account.id ] + } + end + + assert_redirected_to accounts_path + assert_nil first_account.reload.current_account + assert_equal "Crypto", second_account.reload.current_account.accountable_type + assert_equal "exchange", second_account.current_account.accountable.subtype + end + + test "link existing account links manual crypto exchange account to selected kraken account" do + manual_account = manual_crypto_exchange_account + kraken_account = @second_item.kraken_accounts.create!( + name: "Kraken", + account_id: "combined", + account_type: "combined", + currency: "USD", + current_balance: 1000 + ) + + assert_difference "AccountProvider.count", 1 do + post link_existing_account_kraken_items_url, params: { + kraken_item_id: @second_item.id, + account_id: manual_account.id, + kraken_account_id: kraken_account.id + } + end + + assert_redirected_to accounts_path + assert_equal manual_account, kraken_account.reload.current_account + end + + test "link existing account requires explicit connection when multiple items exist" do + account = manual_crypto_exchange_account + + assert_no_difference "AccountProvider.count" do + post link_existing_account_kraken_items_url, params: { + account_id: account.id, + kraken_account_id: "combined" + } + end + + assert_redirected_to settings_providers_path + assert_equal "Choose a Kraken connection before linking accounts.", flash[:alert] + end + + test "link existing account rejects non crypto accounts" do + account = @family.accounts.create!( + name: "Manual Checking", + balance: 0, + currency: "USD", + accountable: Depository.new + ) + kraken_account = @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD") + + assert_no_difference "AccountProvider.count" do + post link_existing_account_kraken_items_url, params: { + kraken_item_id: @second_item.id, + account_id: account.id, + kraken_account_id: kraken_account.id + } + end + + assert_redirected_to account_path(account) + end + + test "link existing account rejects accounts with existing provider links" do + account = manual_crypto_exchange_account + linked_kraken_account = kraken_accounts(:one) + AccountProvider.create!(account: account, provider: linked_kraken_account) + kraken_account = @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD") + + assert_no_difference "AccountProvider.count" do + post link_existing_account_kraken_items_url, params: { + kraken_item_id: @second_item.id, + account_id: account.id, + kraken_account_id: kraken_account.id + } + end + + assert_redirected_to account_path(account) + end + + test "link existing account rejects kraken accounts already linked elsewhere" do + linked_account = manual_crypto_exchange_account + available_account = manual_crypto_exchange_account + kraken_account = @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD") + AccountProvider.create!(account: linked_account, provider: kraken_account) + + assert_no_difference "AccountProvider.count" do + post link_existing_account_kraken_items_url, params: { + kraken_item_id: @second_item.id, + account_id: available_account.id, + kraken_account_id: kraken_account.id + } + end + + assert_redirected_to account_path(available_account) + end + + test "select existing account renders selected kraken item id" do + account = manual_crypto_exchange_account + @second_item.kraken_accounts.create!(name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD") + + get select_existing_account_kraken_items_url, params: { + kraken_item_id: @second_item.id, + account_id: account.id + } + + assert_response :success + assert_includes @response.body, %(name="kraken_item_id") + assert_includes @response.body, %(value="#{@second_item.id}") + end + + test "cannot access another family's kraken item" do + other_item = KrakenItem.create!( + family: families(:empty), + name: "Other Kraken", + api_key: "other_key", + api_secret: "other_secret" + ) + + get setup_accounts_kraken_item_url(other_item) + + assert_response :not_found + end + + private + + def manual_crypto_exchange_account + @family.accounts.create!( + name: "Manual Crypto", + balance: 0, + currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + end +end diff --git a/test/fixtures/kraken_accounts.yml b/test/fixtures/kraken_accounts.yml new file mode 100644 index 000000000..9038ee9dc --- /dev/null +++ b/test/fixtures/kraken_accounts.yml @@ -0,0 +1,8 @@ +one: + kraken_item: one + name: Kraken + account_id: combined + account_type: combined + currency: USD + current_balance: 1234.50 + extra: {} diff --git a/test/fixtures/kraken_items.yml b/test/fixtures/kraken_items.yml new file mode 100644 index 000000000..9181b1c75 --- /dev/null +++ b/test/fixtures/kraken_items.yml @@ -0,0 +1,20 @@ +one: + family: dylan_family + name: My Kraken + api_key: test_kraken_key_123 + api_secret: test_kraken_secret_456 + last_nonce: 0 + status: good + institution_name: Kraken + institution_domain: kraken.com + institution_url: https://www.kraken.com + institution_color: "#5841D8" + +requires_update: + family: dylan_family + name: Stale Kraken + api_key: old_kraken_key + api_secret: old_kraken_secret + last_nonce: 0 + status: requires_update + institution_name: Kraken diff --git a/test/models/kraken_account/asset_normalizer_test.rb b/test/models/kraken_account/asset_normalizer_test.rb new file mode 100644 index 000000000..540caf4d3 --- /dev/null +++ b/test/models/kraken_account/asset_normalizer_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "test_helper" + +class KrakenAccount::AssetNormalizerTest < ActiveSupport::TestCase + test "normalizes kraken symbols through metadata and fallbacks" do + normalizer = KrakenAccount::AssetNormalizer.new( + "XXBT" => { "altname" => "XBT" }, + "XETH" => { "altname" => "ETH" }, + "ZUSD" => { "altname" => "USD" } + ) + + assert_equal "BTC", normalizer.normalize("XXBT")[:symbol] + assert_equal "ETH", normalizer.normalize("XETH")[:symbol] + assert_equal "USD", normalizer.normalize("ZUSD")[:symbol] + end + + test "preserves kraken suffix variants while pricing base asset" do + normalizer = KrakenAccount::AssetNormalizer.new("XETH" => { "altname" => "ETH" }) + + parsed = normalizer.normalize("XETH.F") + + assert_equal "ETH.F", parsed[:symbol] + assert_equal "ETH", parsed[:price_symbol] + assert_equal ".F", parsed[:suffix] + assert_equal "XETH", parsed[:raw_base] + end +end diff --git a/test/models/kraken_account/holdings_processor_test.rb b/test/models/kraken_account/holdings_processor_test.rb new file mode 100644 index 000000000..171e332bf --- /dev/null +++ b/test/models/kraken_account/holdings_processor_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "test_helper" + +class KrakenAccount::HoldingsProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @family.update!(currency: "USD") + @item = KrakenItem.create!( + family: @family, + name: "Kraken", + api_key: "k", + api_secret: "s" + ) + @kraken_account = @item.kraken_accounts.create!( + name: "Kraken", + account_id: "combined", + account_type: "combined", + currency: "USD", + current_balance: 30_000, + raw_payload: { + "assets" => [ + { "symbol" => "BTC", "price_symbol" => "BTC", "balance" => "0.5", "price_usd" => "60000.0", "source" => "spot" } + ] + } + ) + @account = Account.create!( + family: @family, + name: "Kraken", + balance: 0, + currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + @account_provider = AccountProvider.create!(account: @account, provider: @kraken_account) + @security = Security.create!(ticker: "CRYPTO:BTC", name: "BTC", exchange_operating_mic: "XKRA", offline: true) + KrakenAccount::SecurityResolver.stubs(:resolve).returns(@security) + end + + test "imports holdings with account_provider_id" do + import_adapter = mock + import_adapter.expects(:import_holding).with( + has_entries( + security: @security, + quantity: 0.5.to_d, + amount: 30_000.to_d, + currency: "USD", + price: 60_000.to_d, + external_id: "kraken_BTC_spot_#{Date.current}", + account_provider_id: @account_provider.id, + source: "kraken" + ) + ) + Account::ProviderImportAdapter.stubs(:new).returns(import_adapter) + + KrakenAccount::HoldingsProcessor.new(@kraken_account).process + end + + test "does not overwrite a different provider holding with the same security/date/currency" do + binance_item = BinanceItem.create!(family: @family, name: "Binance", api_key: "b", api_secret: "s") + binance_account = binance_item.binance_accounts.create!(name: "Binance", account_type: "combined", currency: "USD") + binance_provider = AccountProvider.create!(account: @account, provider: binance_account) + existing = @account.holdings.create!( + security: @security, + qty: 0.25, + amount: 15_000, + currency: "USD", + date: Date.current, + price: 60_000, + account_provider_id: binance_provider.id + ) + + assert_no_difference -> { @account.holdings.count } do + KrakenAccount::HoldingsProcessor.new(@kraken_account).process + end + + assert_equal binance_provider.id, existing.reload.account_provider_id + assert_nil existing.external_id + assert_nil @account.holdings.find_by(external_id: "kraken_BTC_spot_#{Date.current}") + end + + test "does not log raw asset payloads when holding import fails" do + raw_asset = { + "symbol" => "BTC", + "price_symbol" => "BTC", + "balance" => "0.5", + "price_usd" => "60000.0", + "source" => "spot", + "account_balance_detail" => "sensitive payload" + } + @kraken_account.update!(raw_payload: { "assets" => [ raw_asset ] }) + failing_adapter = mock + failing_adapter.stubs(:import_holding).raises(StandardError, "boom") + Account::ProviderImportAdapter.stubs(:new).returns(failing_adapter) + + Rails.logger.expects(:error) + .with("KrakenAccount::HoldingsProcessor - failed asset symbol=BTC: boom") + + KrakenAccount::HoldingsProcessor.new(@kraken_account).process + end +end diff --git a/test/models/kraken_account/processor_test.rb b/test/models/kraken_account/processor_test.rb new file mode 100644 index 000000000..157b654a7 --- /dev/null +++ b/test/models/kraken_account/processor_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "test_helper" + +class KrakenAccount::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @family.update!(currency: "USD") + @item = KrakenItem.create!(family: @family, name: "Kraken", api_key: "k", api_secret: "s") + @kraken_account = @item.kraken_accounts.create!( + name: "Kraken", + account_id: "combined", + account_type: "combined", + currency: "USD", + current_balance: 1000, + raw_payload: { + "asset_metadata" => { + "XXBT" => { "altname" => "XBT" }, + "ZUSD" => { "altname" => "USD" } + }, + "pair_metadata" => { + "XXBTZUSD" => { "altname" => "XBTUSD", "base" => "XXBT", "quote" => "ZUSD" } + } + }, + raw_transactions_payload: { + "trades" => { + "buy_tx" => trade_payload("buy", "0.001", "50.00", "0.10"), + "sell_tx" => trade_payload("sell", "0.002", "120.00", "0.20") + } + } + ) + @account = Account.create!( + family: @family, + name: "Kraken", + balance: 0, + currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + AccountProvider.create!(account: @account, provider: @kraken_account) + @security = Security.create!(ticker: "CRYPTO:BTC", name: "BTC", exchange_operating_mic: "XKRA", offline: true) + KrakenAccount::SecurityResolver.stubs(:resolve).returns(@security) + KrakenAccount::HoldingsProcessor.any_instance.stubs(:process).returns(nil) + end + + test "imports buy and sell spot fills as trade entries" do + assert_difference -> { @account.entries.where(source: "kraken").count }, 2 do + KrakenAccount::Processor.new(@kraken_account).process + end + + buy = @account.entries.find_by!(external_id: "kraken_trade_buy_tx", source: "kraken") + assert_equal(-50.to_d, buy.amount) + assert_equal "USD", buy.currency + assert_equal 0.001.to_d, buy.trade.qty + assert_equal 50_000.to_d, buy.trade.price + assert_equal 0.10.to_d, buy.trade.fee + assert_equal "Buy", buy.trade.investment_activity_label + + sell = @account.entries.find_by!(external_id: "kraken_trade_sell_tx", source: "kraken") + assert_equal 120.to_d, sell.amount + assert_equal(-0.002.to_d, sell.trade.qty) + assert_equal 0.20.to_d, sell.trade.fee + assert_equal "Sell", sell.trade.investment_activity_label + end + + test "trade import is idempotent by txid" do + assert_difference -> { @account.entries.where(source: "kraken").count }, 2 do + KrakenAccount::Processor.new(@kraken_account).process + end + + assert_no_difference -> { @account.entries.where(source: "kraken").count } do + KrakenAccount::Processor.new(@kraken_account).process + end + end + + test "updates linked crypto account balance without cash balance" do + KrakenAccount::Processor.new(@kraken_account).process + + @account.reload + assert_equal 1000.to_d, @account.balance + assert_equal 0.to_d, @account.cash_balance + assert_equal "USD", @account.currency + end + + private + + def trade_payload(type, volume, cost, fee) + price = volume.to_d.zero? ? 0.to_d : cost.to_d / volume.to_d + + { + "ordertxid" => "order_#{type}", + "pair" => "XBTUSD", + "time" => Time.current.to_f, + "type" => type, + "price" => price.to_s("F"), + "cost" => cost, + "fee" => fee, + "vol" => volume + } + end +end diff --git a/test/models/kraken_item/importer_test.rb b/test/models/kraken_item/importer_test.rb new file mode 100644 index 000000000..2da355bb9 --- /dev/null +++ b/test/models/kraken_item/importer_test.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "test_helper" + +class KrakenItem::ImporterTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = KrakenItem.create!( + family: @family, + name: "Kraken", + api_key: "k", + api_secret: "s" + ) + @provider = mock + @provider.stubs(:get_api_key_info).returns({ "name" => "Sure read-only" }) + @provider.stubs(:get_asset_pairs).returns(pair_metadata) + @provider.stubs(:get_trades_history).returns({ "count" => 0, "trades" => {} }) + @provider.stubs(:get_ticker).returns(nil) + end + + test "creates a combined kraken account from BalanceEx" do + @provider.stubs(:get_asset_info).returns(asset_metadata) + @provider.stubs(:get_extended_balance).returns( + "XXBT" => { "balance" => "1.0", "credit" => "0", "credit_used" => "0", "hold_trade" => "0.25" }, + "ZUSD" => { "balance" => "50.0", "credit" => "0", "credit_used" => "0", "hold_trade" => "0" } + ) + @provider.stubs(:get_ticker).with("XBTUSD").returns("XXBTZUSD" => { "c" => [ "50000.00" ] }) + + assert_difference "@item.kraken_accounts.count", 1 do + KrakenItem::Importer.new(@item, kraken_provider: @provider).import + end + + account = @item.kraken_accounts.first + assert_equal "combined", account.account_id + assert_equal "combined", account.account_type + assert_equal "USD", account.currency + assert_in_delta 50_050, account.current_balance, 0.01 + + btc = account.raw_payload["assets"].find { |asset| asset["symbol"] == "BTC" } + assert_equal "0.75", btc["available"] + assert_equal "0.25", btc["hold_trade"] + end + + test "preserves suffix assets in metadata and marks missing prices" do + @provider.stubs(:get_asset_info).returns(asset_metadata) + @provider.stubs(:get_extended_balance).returns( + "XETH.F" => { "balance" => "2.0", "credit" => "0", "credit_used" => "0", "hold_trade" => "0" } + ) + + KrakenItem::Importer.new(@item, kraken_provider: @provider).import + + account = @item.kraken_accounts.first + eth = account.raw_payload["assets"].first + assert_equal "ETH.F", eth["symbol"] + assert_equal "ETH", eth["price_symbol"] + assert_equal ".F", eth["suffix"] + assert_equal "missing", eth["price_status"] + assert_includes account.extra.dig("kraken", "missing_prices"), "ETH.F" + end + + test "paginates TradesHistory in 50 fill pages" do + @provider.stubs(:get_asset_info).returns({}) + @provider.stubs(:get_extended_balance).returns({}) + first_page = 50.times.to_h { |i| [ "tx#{i}", trade_payload("tx#{i}") ] } + second_page = { "tx50" => trade_payload("tx50") } + + @provider.expects(:get_trades_history).with(start: nil, offset: 0).returns({ "count" => 51, "trades" => first_page }) + @provider.expects(:get_trades_history).with(start: nil, offset: 50).returns({ "count" => 51, "trades" => second_page }) + + result = KrakenItem::Importer.new(@item, kraken_provider: @provider).import + + assert_equal 51, result[:trades_imported] + assert_equal 51, @item.kraken_accounts.first.raw_transactions_payload["trades"].size + end + + test "marks item requires_update when required endpoint reports permission error" do + @provider.stubs(:get_asset_info).returns({}) + @provider.stubs(:get_asset_pairs).returns({}) + @provider.stubs(:get_extended_balance).raises(Provider::Kraken::PermissionError, "EGeneral:Permission denied") + + assert_raises(Provider::Kraken::PermissionError) do + KrakenItem::Importer.new(@item, kraken_provider: @provider).import + end + + assert @item.reload.requires_update? + end + + private + + def asset_metadata + { + "XXBT" => { "altname" => "XBT" }, + "XETH" => { "altname" => "ETH" }, + "ZUSD" => { "altname" => "USD" } + } + end + + def pair_metadata + { + "XXBTZUSD" => { "altname" => "XBTUSD", "base" => "XXBT", "quote" => "ZUSD" } + } + end + + def trade_payload(txid) + { + "ordertxid" => "order_#{txid}", + "pair" => "XBTUSD", + "time" => Time.current.to_f, + "type" => "buy", + "price" => "50000.0", + "cost" => "50.0", + "fee" => "0.1", + "vol" => "0.001" + } + end +end diff --git a/test/models/kraken_item_test.rb b/test/models/kraken_item_test.rb new file mode 100644 index 000000000..69c08d2f2 --- /dev/null +++ b/test/models/kraken_item_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "test_helper" + +class KrakenItemTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = KrakenItem.create!( + family: @family, + name: "My Kraken", + api_key: "test_key", + api_secret: "test_secret" + ) + end + + test "belongs to family" do + assert_equal @family, @item.family + end + + test "has good status by default" do + assert_equal "good", @item.status + end + + test "strips credential whitespace before validation" do + item = KrakenItem.create!( + family: @family, + name: "Whitespace Kraken", + api_key: " key \n", + api_secret: " secret \n" + ) + + assert_equal "key", item.api_key + assert_equal "secret", item.api_secret + end + + test "rejects whitespace-only credentials" do + item = KrakenItem.new(family: @family, name: "Blank Kraken", api_key: " ", api_secret: "\n") + + assert_not item.valid? + assert_includes item.errors[:api_key], "can't be blank" + assert_includes item.errors[:api_secret], "can't be blank" + end + + test "credentials_configured rejects whitespace-only values" do + @item.update_columns(api_key: " ", api_secret: "secret") + + assert_not @item.reload.credentials_configured? + end + + test "next_nonce is monotonic even when stored nonce is ahead of clock" do + @item.update!(last_nonce: 9_000_000_000_000_000_000) + + first = @item.next_nonce!.to_i + second = @item.next_nonce!.to_i + + assert_equal 9_000_000_000_000_000_001, first + assert_equal 9_000_000_000_000_000_002, second + assert_equal second, @item.reload.last_nonce + end + + test "kraken provider uses item nonce generator" do + @item.update!(last_nonce: 9_000_000_000_000_000_000) + provider = @item.kraken_provider + + nonce = provider.send(:nonce_generator).call + + assert_equal "9000000000000000001", nonce + assert_equal 9_000_000_000_000_000_001, @item.reload.last_nonce + end + + test "duplicate combined account ids are scoped by kraken item" do + other_item = KrakenItem.create!( + family: @family, + name: "Other Kraken", + api_key: "other_key", + api_secret: "other_secret" + ) + + @item.kraken_accounts.create!(name: "Main", account_id: "combined", account_type: "combined", currency: "USD") + other_account = other_item.kraken_accounts.create!(name: "Other", account_id: "combined", account_type: "combined", currency: "USD") + + assert other_account.persisted? + end + + test "encrypts credentials when active record encryption is configured" do + skip "Encryption not configured" unless KrakenItem.encryption_ready? + + item = KrakenItem.create!( + family: @family, + name: "Encrypted Kraken", + api_key: "encrypted_key", + api_secret: "encrypted_secret" + ) + + quoted_id = KrakenItem.connection.quote(item.id) + raw = KrakenItem.connection.select_one("SELECT api_key, api_secret FROM kraken_items WHERE id = #{quoted_id}") + assert_not_equal "encrypted_key", raw["api_key"] + assert_not_equal "encrypted_secret", raw["api_secret"] + end +end diff --git a/test/models/provider/kraken_adapter_test.rb b/test/models/provider/kraken_adapter_test.rb new file mode 100644 index 000000000..a9ce06a08 --- /dev/null +++ b/test/models/provider/kraken_adapter_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "test_helper" +require "uri" + +class Provider::KrakenAdapterTest < ActiveSupport::TestCase + setup do + kraken_items(:requires_update).update!(scheduled_for_deletion: true) + end + + test "supports Crypto accounts only" do + assert_includes Provider::KrakenAdapter.supported_account_types, "Crypto" + assert_not_includes Provider::KrakenAdapter.supported_account_types, "Depository" + end + + test "returns fallback connection config when no credentials exist yet" do + family = families(:empty) + configs = Provider::KrakenAdapter.connection_configs(family: family) + + assert_equal 1, configs.length + assert_equal "kraken", configs.first[:key] + assert_equal I18n.t("kraken_items.provider_connection.default_name"), configs.first[:name] + assert configs.first[:can_connect] + end + + test "returns one connection config per credentialed kraken item" do + family = families(:dylan_family) + first_item = kraken_items(:one) + second_item = KrakenItem.create!( + family: family, + name: "Business Kraken", + api_key: "second_kraken_key", + api_secret: "second_kraken_secret" + ) + + configs = Provider::KrakenAdapter.connection_configs(family: family) + + assert_equal [ "kraken_#{second_item.id}", "kraken_#{first_item.id}" ], configs.map { |config| config[:key] } + assert_equal [ + I18n.t("kraken_items.provider_connection.name", name: second_item.name), + I18n.t("kraken_items.provider_connection.name", name: first_item.name) + ], configs.map { |config| config[:name] } + + new_account_uri = URI.parse(configs.first[:new_account_path].call("Crypto", "/accounts")) + assert_equal "/kraken_items/select_accounts", new_account_uri.path + assert_includes new_account_uri.query, "kraken_item_id=#{second_item.id}" + + existing_account_uri = URI.parse(configs.first[:existing_account_path].call(accounts(:crypto).id)) + assert_equal "/kraken_items/select_existing_account", existing_account_uri.path + assert_includes existing_account_uri.query, "kraken_item_id=#{second_item.id}" + end + + test "connection configs ignore whitespace-only credentials" do + family = families(:dylan_family) + blank_item = KrakenItem.create!( + family: family, + name: "Blank Kraken", + api_key: "temporary_key", + api_secret: "temporary_secret" + ) + blank_item.update_columns(api_key: " ", api_secret: " ") + + configs = Provider::KrakenAdapter.connection_configs(family: family) + + assert_equal [ "kraken_#{kraken_items(:one).id}" ], configs.map { |config| config[:key] } + end + + test "build_provider returns nil when family is nil" do + assert_nil Provider::KrakenAdapter.build_provider(family: nil) + end + + test "build_provider returns nil when family has no kraken items" do + assert_nil Provider::KrakenAdapter.build_provider(family: families(:empty)) + end + + test "build_provider returns Kraken provider when only one credentialed item exists" do + provider = Provider::KrakenAdapter.build_provider(family: families(:dylan_family)) + + assert_instance_of Provider::Kraken, provider + end + + test "build_provider requires explicit item when multiple credentialed items exist" do + family = families(:dylan_family) + KrakenItem.create!( + family: family, + name: "Second Kraken", + api_key: "second_kraken_key", + api_secret: "second_kraken_secret" + ) + + assert_nil Provider::KrakenAdapter.build_provider(family: family) + end + + test "build_provider uses explicit kraken item credentials" do + family = families(:dylan_family) + second_item = KrakenItem.create!( + family: family, + name: "Second Kraken", + api_key: " second_kraken_key \n", + api_secret: " second_kraken_secret \n" + ) + + provider = Provider::KrakenAdapter.build_provider(family: family, kraken_item_id: second_item.id) + + assert_instance_of Provider::Kraken, provider + assert_equal "second_kraken_key", provider.api_key + assert_equal "second_kraken_secret", provider.api_secret + end + + test "build_provider refuses kraken items outside the family" do + family = families(:dylan_family) + other_item = KrakenItem.create!( + family: families(:empty), + name: "Other Kraken", + api_key: "other_kraken_key", + api_secret: "other_kraken_secret" + ) + + assert_nil Provider::KrakenAdapter.build_provider(family: family, kraken_item_id: other_item.id) + end + + test "build_provider refuses explicit kraken item without usable credentials" do + family = families(:dylan_family) + blank_item = KrakenItem.create!( + family: family, + name: "Blank Kraken", + api_key: "temporary_key", + api_secret: "temporary_secret" + ) + blank_item.update_columns(api_key: " ", api_secret: " ") + + assert_nil Provider::KrakenAdapter.build_provider(family: family, kraken_item_id: blank_item.id) + end +end diff --git a/test/models/provider/kraken_test.rb b/test/models/provider/kraken_test.rb new file mode 100644 index 000000000..0d233420c --- /dev/null +++ b/test/models/provider/kraken_test.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "test_helper" +require "base64" + +class Provider::KrakenTest < ActiveSupport::TestCase + # Public Kraken docs signing sample, stored as bytes so secret scanners do + # not mistake the test vector for an accidentally committed credential. + OFFICIAL_SAMPLE_SECRET_BYTES = [ + 145, 1, 249, 29, 111, 252, 167, 91, 134, 57, 88, 219, 129, 96, 59, 22, + 233, 192, 152, 99, 188, 150, 196, 148, 92, 219, 46, 221, 234, 48, 239, + 171, 51, 243, 132, 53, 241, 245, 177, 159, 36, 115, 4, 112, 157, 222, + 151, 121, 156, 79, 106, 107, 223, 71, 1, 155, 110, 102, 232, 250, 23, + 88, 110, 94 + ].freeze + OFFICIAL_SAMPLE_SIGNATURE = "4/dpxb3iT4tp/ZCVEwSnEsLxx0bqyhLpdfOpc6fn7OR8+UClSV5n9E6aSS8MPtnRfp32bAb0nmbRn6H8ndwLUQ==" + + setup do + @provider = Provider::Kraken.new(api_key: "test_key", api_secret: official_sample_secret, nonce_generator: -> { "1616492376594" }) + end + + test "sign matches official Kraken Spot REST sample" do + params = { + "nonce" => "1616492376594", + "ordertype" => "limit", + "pair" => "XBTUSD", + "price" => "37500", + "type" => "buy", + "volume" => "1.25" + } + + signature = @provider.send(:sign, "/0/private/AddOrder", params) + + assert_equal OFFICIAL_SAMPLE_SIGNATURE, signature + end + + test "auth headers include api key and signature" do + headers = @provider.send(:auth_headers, "/0/private/BalanceEx", { "nonce" => "1616492376594" }) + + assert_equal "test_key", headers["API-Key"] + assert headers["API-Sign"].present? + assert_equal 64, Base64.strict_decode64(headers["API-Sign"]).bytesize + end + + test "private requests send signed post body and auth headers" do + response = mock_httparty_response(200, { "error" => [], "result" => { "name" => "Sure read-only" } }) + + Provider::Kraken.expects(:post) + .with( + "/0/private/GetApiKeyInfo", + has_entries( + body: "nonce=1616492376594", + headers: has_entries("API-Key" => "test_key", "Content-Type" => "application/x-www-form-urlencoded") + ) + ) + .returns(response) + + assert_equal({ "name" => "Sure read-only" }, @provider.get_api_key_info) + end + + test "handle response returns result on success" do + response = mock_httparty_response(200, { "error" => [], "result" => { "XXBT" => { "balance" => "1.0" } } }) + + assert_equal({ "XXBT" => { "balance" => "1.0" } }, @provider.send(:handle_response, response)) + end + + test "handle response raises api error for non 2xx" do + response = mock_httparty_response(500, { "error" => [ "EService:Unavailable" ] }) + + assert_raises(Provider::Kraken::ApiError) do + @provider.send(:handle_response, response) + end + end + + test "handle response rejects non-envelope payloads" do + response = mock_httparty_response(200, [ "not", "an", "envelope" ]) + + error = assert_raises(Provider::Kraken::ApiError) do + @provider.send(:handle_response, response) + end + + assert_equal "Malformed Kraken API response", error.message + end + + test "handle response requires error key" do + response = mock_httparty_response(200, { "result" => {} }) + + error = assert_raises(Provider::Kraken::ApiError) do + @provider.send(:handle_response, response) + end + + assert_equal "Malformed Kraken API response: missing error", error.message + end + + test "handle response requires result key" do + response = mock_httparty_response(200, { "error" => [] }) + + error = assert_raises(Provider::Kraken::ApiError) do + @provider.send(:handle_response, response) + end + + assert_equal "Malformed Kraken API response: missing result", error.message + end + + test "handle response maps invalid key errors" do + assert_raises(Provider::Kraken::AuthenticationError) do + @provider.send(:handle_response, kraken_error_response("EAPI:Invalid key")) + end + end + + test "handle response maps invalid signature errors" do + assert_raises(Provider::Kraken::AuthenticationError) do + @provider.send(:handle_response, kraken_error_response("EAPI:Invalid signature")) + end + end + + test "handle response maps permission errors" do + assert_raises(Provider::Kraken::PermissionError) do + @provider.send(:handle_response, kraken_error_response("EGeneral:Permission denied")) + end + end + + test "handle response maps rate limit errors" do + assert_raises(Provider::Kraken::RateLimitError) do + @provider.send(:handle_response, kraken_error_response("EAPI:Rate limit exceeded")) + end + end + + test "handle response maps throttled errors as rate limits" do + assert_raises(Provider::Kraken::RateLimitError) do + @provider.send(:handle_response, kraken_error_response("EService:Throttled: 1770000000")) + end + end + + test "handle response maps nonce errors" do + assert_raises(Provider::Kraken::NonceError) do + @provider.send(:handle_response, kraken_error_response("EAPI:Invalid nonce")) + end + end + + test "handle response maps otp required errors" do + assert_raises(Provider::Kraken::OTPRequiredError) do + @provider.send(:handle_response, kraken_error_response("EAPI:Invalid arguments:otp required")) + end + end + + private + + def official_sample_secret + Base64.strict_encode64(OFFICIAL_SAMPLE_SECRET_BYTES.pack("C*")) + end + + def kraken_error_response(error) + mock_httparty_response(200, { "error" => [ error ], "result" => nil }) + end + + def mock_httparty_response(code, body) + response = mock + response.stubs(:code).returns(code) + response.stubs(:parsed_response).returns(body) + response + end +end diff --git a/test/models/provider_connection_status_test.rb b/test/models/provider_connection_status_test.rb index 88ef50866..fa243a715 100644 --- a/test/models/provider_connection_status_test.rb +++ b/test/models/provider_connection_status_test.rb @@ -78,4 +78,15 @@ class ProviderConnectionStatusTest < ActiveSupport::TestCase assert_equal 1, status.dig(:accounts, :linked_count) assert_equal 1, status.dig(:accounts, :unlinked_count) end + + test "kraken provider status is included without credential fields" do + statuses = ProviderConnectionStatus.for_family(families(:dylan_family)) + kraken_status = statuses.find { |status| status[:provider] == "kraken" } + + assert kraken_status + assert_equal "KrakenItem", kraken_status[:provider_type] + refute_includes kraken_status.keys, :api_key + refute_includes kraken_status.keys, :api_secret + assert_equal true, kraken_status[:credentials_configured] + end end diff --git a/test/models/transaction_import_test.rb b/test/models/transaction_import_test.rb index c832b59f1..f3278497e 100644 --- a/test/models/transaction_import_test.rb +++ b/test/models/transaction_import_test.rb @@ -100,7 +100,7 @@ class TransactionImportTest < ActiveSupport::TestCase @import.publish end - assert_equal [ -100, 200, -300 ], @import.entries.map(&:amount) + assert_equal [ -100, 200, -300 ], @import.entries.order(:date).map(&:amount) end test "does not create duplicate when matching transaction exists with same name" do