feat(providers): add Kraken exchange sync (#1759)

* feat(providers): add Kraken exchange sync

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

Closes #1758

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

* fix(providers): address Kraken review findings

* fix(providers): address Kraken review cleanup

* test(imports): stabilize transaction import ordering
This commit is contained in:
ghost
2026-05-11 15:22:37 -07:00
committed by GitHub
parent 33bc6b59c8
commit be598aecf0
44 changed files with 3108 additions and 6 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

153
app/models/kraken_item.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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" },

View File

@@ -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 },

View File

@@ -0,0 +1,115 @@
<%# locals: (kraken_item:, unlinked_count: kraken_item.unlinked_accounts_count) %>
<%= tag.div id: dom_id(kraken_item) do %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-surface-inset">
<%= icon "waves", size: "sm", class: "text-primary" %>
</div>
<div class="pl-1 text-sm flex-1">
<div class="flex items-center gap-2">
<%= tag.p kraken_item.institution_display_name, class: "font-medium text-primary" %>
<% if kraken_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<p class="text-xs text-secondary"><%= t(".provider_name") %></p>
<% if kraken_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif kraken_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".reconnect") %>
</div>
<% else %>
<p class="text-secondary">
<% 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 %>
</p>
<% end %>
</div>
</summary>
<% if Current.user&.admin? %>
<div class="flex items-center justify-end gap-2 mt-2">
<%= 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 %>
</div>
<% end %>
<% unless kraken_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% if kraken_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: kraken_item.accounts %>
<% kraken_item.stale_rate_accounts.each do |kraken_account| %>
<div class="flex items-center gap-2 text-xs text-warning px-1">
<span class="font-mono">~</span>
<%= icon "triangle-alert", size: "sm" %>
<span><%= t(".stale_rate_warning", date: kraken_account.extra.dig("kraken", "rate_target_date")) %></span>
</div>
<% 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? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "plus",
variant: "primary",
href: setup_accounts_kraken_item_path(kraken_item),
frame: :modal
) %>
</div>
<% elsif kraken_item.accounts.empty? && kraken_item.kraken_accounts.none? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_message") %></p>
</div>
<% end %>
</div>
<% end %>
</details>
<% end %>

View File

@@ -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? %>
<div class="p-4 text-sm text-secondary">
<p class="mb-2"><%= t(".no_accounts_found") %></p>
<ul class="list-disc list-inside space-y-1">
<li><%= t(".wait_for_sync") %></li>
<li><%= t(".check_provider_health") %></li>
</ul>
</div>
<% 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 %>
<div class="space-y-2 max-h-64 overflow-auto">
<% @available_kraken_accounts.each do |kraken_account| %>
<label class="flex items-center gap-3 p-2 rounded border border-secondary hover:border-primary cursor-pointer">
<%= radio_button_tag :kraken_account_id, kraken_account.id, false, required: true %>
<div class="flex flex-col">
<span class="text-sm text-primary font-medium"><%= kraken_account.name.presence || kraken_account.id %></span>
<span class="text-xs text-secondary">
<%= kraken_account.currency %> &bull; <%= number_with_delimiter(kraken_account.current_balance || 0, delimiter: ",") %>
</span>
</div>
</label>
<% end %>
</div>
<div class="flex items-center justify-end gap-2">
<%= 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" }) %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,89 @@
<% content_for :title, t(".title") %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title")) do %>
<div class="flex items-center gap-2">
<%= icon "waves", class: "text-primary" %>
<span class="text-primary"><%= t(".subtitle") %></span>
</div>
<% 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| %>
<div class="space-y-4">
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<p class="text-sm text-primary"><%= t(".instructions") %></p>
</div>
</div>
<% if @kraken_accounts.empty? %>
<div class="text-center py-8">
<p class="text-secondary"><%= t(".no_accounts") %></p>
</div>
<% else %>
<div data-controller="select-all">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-secondary">
<%= t(".accounts_count", count: @kraken_accounts.count) %>
</span>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox"
id="kraken-select-all"
data-action="change->select-all#toggle"
class="checkbox checkbox--dark">
<span class="text-secondary"><%= t(".select_all") %></span>
</label>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<% @kraken_accounts.each do |kraken_account| %>
<label for="ka_<%= kraken_account.id %>" class="flex items-center gap-3 p-3 border border-primary rounded-lg hover:bg-surface transition-colors cursor-pointer">
<%= check_box_tag "selected_accounts[]",
kraken_account.id,
false,
id: "ka_#{kraken_account.id}",
class: "checkbox checkbox--dark",
data: { select_all_target: "checkbox" } %>
<div class="flex-1 min-w-0">
<p class="font-medium text-primary truncate"><%= kraken_account.name %></p>
<p class="text-xs text-secondary"><%= kraken_account.account_type %></p>
</div>
<div class="text-right flex-shrink-0">
<p class="text-sm font-medium text-primary">
<%= number_with_delimiter(kraken_account.current_balance || 0, delimiter: ",") %>
</p>
<p class="text-xs text-secondary"><%= kraken_account.currency %></p>
</div>
</label>
<% end %>
</div>
</div>
<% end %>
</div>
<div class="flex gap-3">
<%= 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) %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,141 @@
<div id="kraken-providers-panel" class="space-y-4">
<% 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? %>
<div class="space-y-3">
<% items.each do |item| %>
<details class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-surface-inset">
<%= icon "waves", size: "sm", class: "text-primary" %>
</div>
<div class="min-w-0">
<p class="font-medium text-primary truncate"><%= item.name %></p>
<p class="text-xs text-secondary">
<% if item.syncing? %>
<%= t("settings.providers.kraken_panel.syncing") %>
<% else %>
<%= item.sync_status_summary %>
<% end %>
</p>
</div>
</div>
</summary>
<div class="mt-4 space-y-4">
<div class="flex flex-wrap items-center gap-2">
<%= 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 %>
</div>
<%= 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 %>
<div class="flex justify-end">
<%= 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" %>
</div>
<% end %>
</div>
</details>
<% end %>
</div>
<% end %>
<% kraken_item = Current.family.kraken_items.build(name: t("settings.providers.kraken_panel.default_connection_name")) %>
<% if items.any? %>
<h3 class="flex items-center gap-2 text-sm font-medium text-primary mt-4">
<%= icon "plus", size: "sm" %>
<%= t("settings.providers.kraken_panel.add_connection") %>
</h3>
<% 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 %>
<div class="flex justify-end">
<%= 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" %>
</div>
<% end %>
</div>

View File

@@ -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...

View File

@@ -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 <a href="https://pro.kraken.com/app/settings/api" target="_blank" rel="noopener noreferrer" class="underline">Kraken API settings</a>'
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

View File

@@ -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

View File

@@ -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

49
db/schema.rb generated
View File

@@ -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"

View File

@@ -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

View File

@@ -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

8
test/fixtures/kraken_accounts.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
one:
kraken_item: one
name: Kraken
account_id: combined
account_type: combined
currency: USD
current_balance: 1234.50
extra: {}

20
test/fixtures/kraken_items.yml vendored Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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