Files
sure/test/models/kraken_item/importer_test.rb
ghost d329a4f69d feat(kraken): import deposits, withdrawals, staking & fees via Ledgers API (#2451)
* feat(kraken): fetch and import Ledgers API for deposits, withdrawals, staking, fees

Closes #2450

Kraken TradesHistory only returns spot buy/sell trades. The Ledgers API
(/0/private/Ledgers) covers deposits, withdrawals, staking rewards, Earn
income, and standalone fees — everything that was missing from syncs.

Changes:
- Provider::Kraken#get_ledgers — new method forwarding start/type/offset params
- KrakenItem::Importer#fetch_ledgers — paginated fetch (up to 200 pages) with
  graceful fallback if the API key lacks Query Ledger Entries permission
- Importer#upsert_kraken_account — stores "ledgers" alongside "trades" in
  raw_transactions_payload
- KrakenAccount::LedgerProcessor — new class; maps each supported ledger type
  (deposit, withdrawal, staking, earn, fee) to a Transaction entry with the
  correct investment_activity_label, kind, and sign convention; skips trade/
  transfer/margin types to avoid double-counting with TradesHistory
- KrakenAccount::Processor#process — calls LedgerProcessor after process_trades
- Multi-currency: fiat amounts converted via ExchangeRate (non-USD fiat bridged
  through USD); crypto amounts use the spot price cached in raw_payload["assets"]
  with a price_missing flag when no price is available
- Dedup guard: external_id "kraken_ledger_<id>" + source "kraken" prevents
  re-importing on repeated syncs

* fix(kraken): correct sign convention, fee inclusion, and earn subtype filtering

- Sign convention: deposits/staking/earn → negative (inflow), withdrawals/fees
  → positive (outflow), matching Sure's global convention (inflow is negative)
- Fee inclusion: use (amount - fee).abs as abs_impact so withdrawal fees are
  counted in the total outflow rather than discarded
- Earn subtypes: skip allocation/deallocation ledger entries (internal fund
  movements); only import rewardallocation/bonusallocation as Interest income
- DebugLogEntry: replace Rails.logger.warn/error with DebugLogEntry.capture
  throughout LedgerProcessor and the Ledgers permission fallback in Importer,
  so support-relevant incidents surface in /settings/debug
- Importer test: stub get_ledgers in setup so existing tests do not error
  on the new fetch_ledgers call

* fix(kraken): route duplicate ledger-id warning through DebugLogEntry

* perf(kraken): batch ledger idempotency check; strengthen tests

Address review feedback (jjmata):

- N+1: LedgerProcessor#process_ledger_entry ran `account.entries.exists?(...)`
  per ledger entry (up to ~10k per sync). Load the existing Kraken external IDs
  once into a Set and test membership in memory (newly created IDs are added so
  the same run stays idempotent) — same pattern as #2452.
- Tests: the idempotency test now asserts the first pass actually creates the
  entry (assert_difference) before asserting the second is a no-op; add a guard
  asserting the second (all-skipped) pass issues a single bulk external_id pluck,
  not one query per entry.

No behavior change to imported entries.

* perf(kraken): scope ledger idempotency pluck to kraken_ledger_ prefix

Only load existing ledger external IDs (not trade entries) into the idempotency
Set, matching the reviewed approach. No behavior change.
2026-06-30 07:35:14 +02:00

118 lines
4.2 KiB
Ruby

# 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_ledgers).returns({ "ledger" => {}, "count" => 0 })
@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