Files
sure/app/models/kraken_account/processor.rb
ghost be598aecf0 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
2026-05-12 00:22:37 +02:00

123 lines
3.7 KiB
Ruby

# 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