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 %>
+ <%= t(".deletion_in_progress") %> <%= t(".provider_name") %>
+ <% 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 %>
+ <%= t(".setup_needed") %> <%= t(".setup_description") %> <%= t(".no_accounts_title") %> <%= t(".no_accounts_message") %>
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
+
+
+
+ <% if Current.user&.admin? %>
+
<%= t(".no_accounts_found") %>
+<%= t(".instructions") %>
+<%= t(".no_accounts") %>
+<%= item.name %>
++ <% if item.syncing? %> + <%= t("settings.providers.kraken_panel.syncing") %> + <% else %> + <%= item.sync_status_summary %> + <% end %> +
+