mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 05:24:57 +00:00
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:
241
app/controllers/kraken_items_controller.rb
Normal file
241
app/controllers/kraken_items_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
29
app/models/family/kraken_connectable.rb
Normal file
29
app/models/family/kraken_connectable.rb
Normal 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
|
||||
39
app/models/kraken_account.rb
Normal file
39
app/models/kraken_account.rb
Normal 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
|
||||
67
app/models/kraken_account/asset_normalizer.rb
Normal file
67
app/models/kraken_account/asset_normalizer.rb
Normal 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
|
||||
84
app/models/kraken_account/holdings_processor.rb
Normal file
84
app/models/kraken_account/holdings_processor.rb
Normal 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
|
||||
122
app/models/kraken_account/processor.rb
Normal file
122
app/models/kraken_account/processor.rb
Normal 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
|
||||
16
app/models/kraken_account/security_resolver.rb
Normal file
16
app/models/kraken_account/security_resolver.rb
Normal 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
|
||||
32
app/models/kraken_account/usd_converter.rb
Normal file
32
app/models/kraken_account/usd_converter.rb
Normal 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
153
app/models/kraken_item.rb
Normal 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
|
||||
187
app/models/kraken_item/importer.rb
Normal file
187
app/models/kraken_item/importer.rb
Normal 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
|
||||
15
app/models/kraken_item/provided.rb
Normal file
15
app/models/kraken_item/provided.rb
Normal 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
|
||||
20
app/models/kraken_item/sync_complete_event.rb
Normal file
20
app/models/kraken_item/sync_complete_event.rb
Normal 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
|
||||
81
app/models/kraken_item/syncer.rb
Normal file
81
app/models/kraken_item/syncer.rb
Normal 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
|
||||
37
app/models/kraken_item/unlinking.rb
Normal file
37
app/models/kraken_item/unlinking.rb
Normal 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
|
||||
153
app/models/provider/kraken.rb
Normal file
153
app/models/provider/kraken.rb
Normal 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
|
||||
110
app/models/provider/kraken_adapter.rb
Normal file
110
app/models/provider/kraken_adapter.rb
Normal 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
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
115
app/views/kraken_items/_kraken_item.html.erb
Normal file
115
app/views/kraken_items/_kraken_item.html.erb
Normal 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 %>
|
||||
42
app/views/kraken_items/select_existing_account.html.erb
Normal file
42
app/views/kraken_items/select_existing_account.html.erb
Normal 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 %> • <%= 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 %>
|
||||
89
app/views/kraken_items/setup_accounts.html.erb
Normal file
89
app/views/kraken_items/setup_accounts.html.erb
Normal 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 %>
|
||||
141
app/views/settings/providers/_kraken_panel.html.erb
Normal file
141
app/views/settings/providers/_kraken_panel.html.erb
Normal 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>
|
||||
85
config/locales/views/kraken_items/en.yml
Normal file
85
config/locales/views/kraken_items/en.yml
Normal 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...
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
49
db/schema.rb
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
278
test/controllers/kraken_items_controller_test.rb
Normal file
278
test/controllers/kraken_items_controller_test.rb
Normal 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
8
test/fixtures/kraken_accounts.yml
vendored
Normal 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
20
test/fixtures/kraken_items.yml
vendored
Normal 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
|
||||
28
test/models/kraken_account/asset_normalizer_test.rb
Normal file
28
test/models/kraken_account/asset_normalizer_test.rb
Normal 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
|
||||
100
test/models/kraken_account/holdings_processor_test.rb
Normal file
100
test/models/kraken_account/holdings_processor_test.rb
Normal 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
|
||||
100
test/models/kraken_account/processor_test.rb
Normal file
100
test/models/kraken_account/processor_test.rb
Normal 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
|
||||
116
test/models/kraken_item/importer_test.rb
Normal file
116
test/models/kraken_item/importer_test.rb
Normal 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
|
||||
100
test/models/kraken_item_test.rb
Normal file
100
test/models/kraken_item_test.rb
Normal 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
|
||||
134
test/models/provider/kraken_adapter_test.rb
Normal file
134
test/models/provider/kraken_adapter_test.rb
Normal 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
|
||||
163
test/models/provider/kraken_test.rb
Normal file
163
test/models/provider/kraken_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user